@adeu/core 1.17.1 → 1.18.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/src/ingest.ts CHANGED
@@ -184,13 +184,28 @@ export function extract_table(
184
184
 
185
185
  if (!first_cell) cell_cursor += 3;
186
186
 
187
- const cell_content = _extract_blocks(
187
+ let cell_content = _extract_blocks(
188
188
  cell,
189
189
  comments_map,
190
190
  cleanView,
191
191
  cell_cursor,
192
192
  paragraph_offsets,
193
193
  );
194
+ // Emit a stable, document-native anchor for this cell so empty/short
195
+ // value cells are addressable by the engine. Reuses the {#...} bookmark
196
+ // projection (already protected by validate_edit_strings and resolvable
197
+ // via the mapper). We key on the cell's first paragraph w14:paraId, which
198
+ // Word assigns and keeps stable across reads.
199
+ if (!cleanView) {
200
+ const firstP = cell._element.getElementsByTagName("w:p")[0] as
201
+ | Element
202
+ | undefined;
203
+ const paraId = firstP ? firstP.getAttribute("w14:paraId") : null;
204
+ if (paraId) {
205
+ const anchor = `{#cell:${paraId}}`;
206
+ cell_content = cell_content + anchor;
207
+ }
208
+ }
194
209
  cell_texts.push(cell_content);
195
210
  cell_cursor += cell_content.length;
196
211
  first_cell = false;
package/src/mapper.ts CHANGED
@@ -260,7 +260,29 @@ export class DocumentMapper {
260
260
  current += 3;
261
261
  }
262
262
 
263
+ const cell_start = current;
263
264
  current = this._map_blocks(cell, current);
265
+
266
+ // Parity with ingest.extract_table: emit a {#cell:<paraId>} anchor and
267
+ // bind a zero-width span to the cell's first paragraph so the engine
268
+ // can resolve "write into this cell" even when the cell is empty
269
+ // (pPr-only paragraph with no run).
270
+ if (!this.clean_view && !this.original_view) {
271
+ const firstP = cell._element.getElementsByTagName("w:p")[0] as
272
+ | Element
273
+ | undefined;
274
+ const paraId = firstP ? firstP.getAttribute("w14:paraId") : null;
275
+ if (paraId && firstP) {
276
+ // Zero-width span bound to the empty cell paragraph: gives
277
+ // get_insertion_anchor a paragraph to land on. Placed at the anchor
278
+ // token offset so resolution targets THIS cell, not a neighbour.
279
+ const cellPara = new Paragraph(firstP, cell);
280
+ this._add_virtual_text("", current, cellPara);
281
+ const anchor = `{#cell:${paraId}}`;
282
+ this._add_virtual_text(anchor, current, cellPara);
283
+ current += anchor.length;
284
+ }
285
+ }
264
286
  cells_processed += 1;
265
287
  }
266
288
 
@@ -738,7 +760,7 @@ export class DocumentMapper {
738
760
  target_text = this._replace_smart_quotes(target_text);
739
761
 
740
762
  const parts: string[] = [];
741
- const token_pattern = /(\[_+\])|(\s+)|(['"])|([.,;:\/])/g;
763
+ const token_pattern = /(\[_+\])|(\s+)|(['"])|([.,;:\/\-\[\](){}+=$?*!|#^<>\\%&@~`_])/g;
742
764
 
743
765
  let last_idx = 0;
744
766
  let match;
package/src/markup.ts CHANGED
@@ -138,7 +138,7 @@ export function _make_fuzzy_regex(target_text: string): string {
138
138
  target_text = _replace_smart_quotes(target_text);
139
139
 
140
140
  const parts: string[] = [];
141
- const token_pattern = /(_+)|(\s+)|(['"])|([.,;:\/])/g;
141
+ const token_pattern = /(_+)|(\s+)|(['"])|([.,;:\/\-\[\](){}+=$?*!|#^<>\\%&@~`])/g;
142
142
 
143
143
  // Note: JS does not support atomic groups (?>...).
144
144
  // However, because we only match markdown characters * and _,
@@ -0,0 +1,137 @@
1
+ /**
2
+ * Canonical track-change semantics for editing inside another author's pending
3
+ * insertion, plus reject_all_revisions round-trips. Mirrors the Python suite
4
+ * python/tests/test_reject_and_nested_redline.py (Python is canonical).
5
+ *
6
+ * When an author edits/deletes text inside another author's still-pending
7
+ * <w:ins>, the deletion is NESTED inside that <w:ins> and the replacement is a
8
+ * SIBLING after it (splitting the insertion if the edit is mid-insertion);
9
+ * <w:ins> is never nested in <w:ins>. This preserves authorship and makes
10
+ * reject-all revert the contingent text to nothing rather than promoting an
11
+ * unaccepted proposal to committed body text.
12
+ */
13
+ import { describe, it, expect } from "vitest";
14
+ import { createTestDocument, addParagraph } from "./test-utils.js";
15
+ import { RedlineEngine } from "./engine.js";
16
+ import { extractTextFromBuffer } from "./ingest.js";
17
+
18
+ function foreignInsDoc(doc: any) {
19
+ const xmlDoc = doc.element.ownerDocument!;
20
+ const p = addParagraph(doc, "The party shall provide ");
21
+ const ins = xmlDoc.createElement("w:ins");
22
+ ins.setAttribute("w:id", "201");
23
+ ins.setAttribute("w:author", "Supplier's Counsel");
24
+ const r = xmlDoc.createElement("w:r");
25
+ const t = xmlDoc.createElement("w:t");
26
+ t.textContent = "written notice";
27
+ r.appendChild(t);
28
+ ins.appendChild(r);
29
+ p.appendChild(ins);
30
+ const r2 = xmlDoc.createElement("w:r");
31
+ const t2 = xmlDoc.createElement("w:t");
32
+ t2.textContent = " within 30 days.";
33
+ r2.appendChild(t2);
34
+ p.appendChild(r2);
35
+ return p;
36
+ }
37
+
38
+ async function cleanText(doc: any): Promise<string> {
39
+ return await extractTextFromBuffer(await doc.save());
40
+ }
41
+
42
+ describe("Canonical nesting + reject round-trips", () => {
43
+ it("modify inside a foreign insertion nests del and emits a sibling ins (no ins-in-ins)", async () => {
44
+ const doc = await createTestDocument();
45
+ const p = foreignInsDoc(doc);
46
+ const engine = new RedlineEngine(doc, "Reviewer AI");
47
+ const res = engine.process_batch(
48
+ [{ type: "modify", target_text: "written notice", new_text: "email notification" }] as any,
49
+ false,
50
+ );
51
+ expect(res.edits_applied).toBe(1);
52
+ expect(res.edits_skipped).toBe(0);
53
+
54
+ // del nested inside Supplier's <w:ins>; no <w:ins> nested in <w:ins>.
55
+ const insNodes = Array.from(p.getElementsByTagName("w:ins"));
56
+ const supplierIns = insNodes.find(
57
+ (i) => i.getAttribute("w:author") === "Supplier's Counsel",
58
+ )!;
59
+ expect(supplierIns).toBeTruthy();
60
+ expect(supplierIns.getElementsByTagName("w:del").length).toBeGreaterThan(0);
61
+ for (const i of insNodes) {
62
+ expect(i.getElementsByTagName("w:ins").length).toBe(0);
63
+ }
64
+ // Reviewer's replacement insertion exists as a separate node.
65
+ expect(
66
+ insNodes.some(
67
+ (i) =>
68
+ i.getAttribute("w:author") === "Reviewer AI" &&
69
+ (i.textContent || "").includes("email notification"),
70
+ ),
71
+ ).toBe(true);
72
+ });
73
+
74
+ it("accept-all and reject-all round-trip correctly for a foreign-ins modify", async () => {
75
+ const accDoc = await createTestDocument();
76
+ foreignInsDoc(accDoc);
77
+ const ae = new RedlineEngine(accDoc, "Reviewer AI");
78
+ ae.process_batch(
79
+ [{ type: "modify", target_text: "written notice", new_text: "email notification" }] as any,
80
+ false,
81
+ );
82
+ ae.accept_all_revisions();
83
+ expect(await cleanText(accDoc)).toContain(
84
+ "The party shall provide email notification within 30 days.",
85
+ );
86
+
87
+ const rejDoc = await createTestDocument();
88
+ foreignInsDoc(rejDoc);
89
+ const re = new RedlineEngine(rejDoc, "Reviewer AI");
90
+ re.process_batch(
91
+ [{ type: "modify", target_text: "written notice", new_text: "email notification" }] as any,
92
+ false,
93
+ );
94
+ re.reject_all_revisions();
95
+ const rejected = await cleanText(rejDoc);
96
+ // Reject reverts to the true baseline: the pending insertion vanishes.
97
+ expect(rejected).not.toContain("written notice");
98
+ expect(rejected).not.toContain("email notification");
99
+ expect(rejected).toContain("The party shall provide within 30 days.");
100
+ });
101
+
102
+ it("insertion inside a foreign ins splits (no ins-in-ins) and round-trips", async () => {
103
+ const doc = await createTestDocument();
104
+ const p = foreignInsDoc(doc);
105
+ const e = new RedlineEngine(doc, "Reviewer AI");
106
+ // A modify that trims to a pure INSERTION inside the foreign <w:ins>.
107
+ const res = e.process_batch(
108
+ [{ type: "modify", target_text: "written notice", new_text: "written notice please" }] as any,
109
+ false,
110
+ );
111
+ expect(res.edits_applied).toBe(1);
112
+ for (const i of Array.from(p.getElementsByTagName("w:ins"))) {
113
+ expect(i.getElementsByTagName("w:ins").length).toBe(0);
114
+ }
115
+
116
+ const ad = await createTestDocument();
117
+ foreignInsDoc(ad);
118
+ const ae = new RedlineEngine(ad, "Reviewer AI");
119
+ ae.process_batch(
120
+ [{ type: "modify", target_text: "written notice", new_text: "written notice please" }] as any,
121
+ false,
122
+ );
123
+ ae.accept_all_revisions();
124
+ expect(await cleanText(ad)).toContain(
125
+ "The party shall provide written notice please within 30 days.",
126
+ );
127
+ });
128
+
129
+ it("reject-all restores the original for an own-author edit", async () => {
130
+ const doc = await createTestDocument();
131
+ addParagraph(doc, "The cat sat.");
132
+ const e = new RedlineEngine(doc, "Z");
133
+ e.process_batch([{ type: "modify", target_text: "cat", new_text: "dog" }] as any, false);
134
+ e.reject_all_revisions();
135
+ expect(await cleanText(doc)).toContain("The cat sat.");
136
+ });
137
+ });
@@ -0,0 +1,160 @@
1
+ // FILE: node/packages/core/src/repro.feedback.test.ts
2
+ //
3
+ // Regression repros for the field observations against the Node engine.
4
+ //
5
+ // STYLE: these assert the DESIRED (correct) behaviour, so a test is RED while
6
+ // the bug is present and turns GREEN once the engine is fixed. (This is the
7
+ // "isolate the bug before fixing" pattern from AI_CONTEXT.md > Testing.)
8
+ //
9
+ // Issue 1 — accept + modify the same span in one batch -> currently RED (bug)
10
+ // Issue 2 — comment-only run-shredding -> NOT a bug; see note
11
+ //
12
+ // (Issue 3 lives in node/packages/mcp-server/src/repro.feedback.test.ts.)
13
+
14
+ import { describe, it, expect } from "vitest";
15
+ import { RedlineEngine } from "./engine.js";
16
+ import { createTestDocument, addParagraph } from "./test-utils.js";
17
+
18
+ /** Append a paragraph that already contains a tracked insertion by `author`. */
19
+ function addParagraphWithForeignInsertion(
20
+ doc: any,
21
+ prefix: string,
22
+ insertedText: string,
23
+ insId: string,
24
+ author: string,
25
+ ): Element {
26
+ const x = doc.element.ownerDocument!;
27
+ const p = x.createElement("w:p");
28
+ const r0 = x.createElement("w:r");
29
+ const t0 = x.createElement("w:t");
30
+ t0.textContent = prefix;
31
+ t0.setAttribute("xml:space", "preserve");
32
+ r0.appendChild(t0);
33
+ p.appendChild(r0);
34
+
35
+ const ins = x.createElement("w:ins");
36
+ ins.setAttribute("w:id", insId);
37
+ ins.setAttribute("w:author", author);
38
+ ins.setAttribute("w:date", "2024-01-01T00:00:00Z");
39
+ const r = x.createElement("w:r");
40
+ const t = x.createElement("w:t");
41
+ t.textContent = insertedText;
42
+ r.appendChild(t);
43
+ ins.appendChild(r);
44
+ p.appendChild(ins);
45
+
46
+ doc.element.appendChild(p);
47
+ return p;
48
+ }
49
+
50
+ function countTag(el: any, tag: string): number {
51
+ let n = 0;
52
+ const walk = (node: any) => {
53
+ if (node.tagName === tag) n++;
54
+ let c = node.firstChild;
55
+ while (c) {
56
+ if (c.nodeType === 1) walk(c);
57
+ c = c.nextSibling;
58
+ }
59
+ };
60
+ walk(el);
61
+ return n;
62
+ }
63
+
64
+ describe("Field feedback repro — Node engine", () => {
65
+ // ───────────────────────────────────────────────────────────────────────
66
+ // ISSUE 1 — RED until fixed.
67
+ //
68
+ // Accepting a foreign author's insertion and then editing that now-accepted
69
+ // text, in a SINGLE batch, MUST succeed: by the time the modify runs the span
70
+ // has been accepted and is no longer foreign. Node rejects it because the
71
+ // unified up-front validation pass (engine.ts `_process_batch_internal`, the
72
+ // `validate_edits(edits)` call BEFORE `apply_review_actions`) validates the
73
+ // modify against the PRE-accept document. This test fails today and will pass
74
+ // once validation runs after the accepts are applied.
75
+ // ───────────────────────────────────────────────────────────────────────
76
+ it("Issue 1: accept + modify of the SAME foreign insertion in one batch should succeed", async () => {
77
+ const doc = await createTestDocument();
78
+ addParagraphWithForeignInsertion(
79
+ doc,
80
+ "The term is ",
81
+ "24 months",
82
+ "5",
83
+ "Supplier's Counsel",
84
+ );
85
+
86
+ const engine = new RedlineEngine(doc, "Acme's Counsel");
87
+ const batch = [
88
+ { type: "accept", target_id: "Chg:5" },
89
+ { type: "modify", target_text: "24 months", new_text: "36 months" },
90
+ ] as any[];
91
+
92
+ // DESIRED behaviour — RED on current Node (throws "active insertion from
93
+ // another author"), GREEN after the fix.
94
+ const stats = engine.process_batch(batch, false);
95
+ expect(stats.actions_applied).toBe(1);
96
+ expect(stats.edits_applied).toBe(1);
97
+ expect(stats.edits_skipped).toBe(0);
98
+ });
99
+
100
+ it("Issue 1 (control, GREEN): the same accept + modify succeeds across two batches", async () => {
101
+ // Proves the rejection above is purely a single-round-trip batching
102
+ // artifact — the content and intent are valid.
103
+ const doc = await createTestDocument();
104
+ addParagraphWithForeignInsertion(
105
+ doc,
106
+ "The term is ",
107
+ "24 months",
108
+ "5",
109
+ "Supplier's Counsel",
110
+ );
111
+
112
+ const engine = new RedlineEngine(doc, "Acme's Counsel");
113
+ const r1 = engine.process_batch([{ type: "accept", target_id: "Chg:5" }], false);
114
+ expect(r1.actions_applied).toBe(1);
115
+ const r2 = engine.process_batch(
116
+ [{ type: "modify", target_text: "24 months", new_text: "36 months" }],
117
+ false,
118
+ );
119
+ expect(r2.edits_applied).toBe(1);
120
+ });
121
+
122
+ // ───────────────────────────────────────────────────────────────────────
123
+ // ISSUE 2 — NOT a bug in the current engine, so there is nothing to fail on.
124
+ // The described mechanism (one match whose physical runs are shredded →
125
+ // comment id duplicated per run segment) does not occur: COMMENT_ONLY anchors
126
+ // a single range across the first→last run regardless of fragmentation. These
127
+ // assertions are GREEN BY DESIGN and exist as evidence for the "not
128
+ // replicable" conclusion. (The real gap — no dedicated comment-only change
129
+ // type, so comments must ride a self-replacing ModifyText — is a feature
130
+ // request, not a failing-test-able defect.)
131
+ // ───────────────────────────────────────────────────────────────────────
132
+ it("Issue 2 (GREEN, evidence): self-replacement comment over 9 fragmented runs emits ONE comment", async () => {
133
+ const doc = await createTestDocument();
134
+ const x = doc.element.ownerDocument!;
135
+ const p = x.createElement("w:p");
136
+ const words = [
137
+ "The ", "Purchase ", "Price ", "is ", "[ ] ",
138
+ "dollars ", "per ", "unit ", "total.",
139
+ ];
140
+ words.forEach((w, i) => {
141
+ if (i % 2 === 0) p.appendChild(x.createElement("w:proofErr"));
142
+ const r = x.createElement("w:r");
143
+ const t = x.createElement("w:t");
144
+ t.textContent = w;
145
+ t.setAttribute("xml:space", "preserve");
146
+ r.appendChild(t);
147
+ p.appendChild(r);
148
+ });
149
+ doc.element.appendChild(p);
150
+
151
+ const engine = new RedlineEngine(doc, "Reviewer");
152
+ const sentence = words.join("").trim();
153
+ engine.process_batch(
154
+ [{ type: "modify", target_text: sentence, new_text: sentence, comment: "Missing value" }],
155
+ false,
156
+ );
157
+
158
+ expect(countTag(doc.element, "w:commentReference")).toBe(1);
159
+ });
160
+ });
@@ -0,0 +1,132 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { createTestDocument, addParagraph, addTable, setCellText } from "./test-utils.js";
3
+ import { RedlineEngine } from "./engine.js";
4
+ import { DocumentObject } from "./docx/bridge.js";
5
+
6
+ describe("Webinar Report Bug Reproductions", () => {
7
+ it("Bug 1: Diff Engine Fragility (Token-Splitting Punctuation)", async () => {
8
+ const doc = await createTestDocument();
9
+ const xmlDoc = doc.element.ownerDocument!;
10
+ const p = xmlDoc.createElement("w:p");
11
+
12
+ // Replicate MS Word fragmented runs for token-splitting punctuation: [Company Name]
13
+ // with style/formatting run fragmentation where only some runs are bold.
14
+ // This generates: **[**Company Name**]**
15
+ // The target "[Company Name]" will fail to be matched because brackets are not treated
16
+ // as fuzzy boundaries, so the fuzzy regex expects "[Company" directly without the intermediate "**".
17
+ const r1 = xmlDoc.createElement("w:r");
18
+ const rPr1 = xmlDoc.createElement("w:rPr");
19
+ rPr1.appendChild(xmlDoc.createElement("w:b"));
20
+ r1.appendChild(rPr1);
21
+ const t1 = xmlDoc.createElement("w:t");
22
+ t1.textContent = "[";
23
+ r1.appendChild(t1);
24
+ p.appendChild(r1);
25
+
26
+ const r2 = xmlDoc.createElement("w:r");
27
+ // Run 2 is NOT bold
28
+ const t2 = xmlDoc.createElement("w:t");
29
+ t2.textContent = "Company Name";
30
+ r2.appendChild(t2);
31
+ p.appendChild(r2);
32
+
33
+ const r3 = xmlDoc.createElement("w:r");
34
+ const rPr3 = xmlDoc.createElement("w:rPr");
35
+ rPr3.appendChild(xmlDoc.createElement("w:b"));
36
+ r3.appendChild(rPr3);
37
+ const t3 = xmlDoc.createElement("w:t");
38
+ t3.textContent = "]";
39
+ r3.appendChild(t3);
40
+ p.appendChild(r3);
41
+
42
+ doc.element.appendChild(p);
43
+
44
+ const engine = new RedlineEngine(doc);
45
+
46
+ // Under correct engine behavior, targeting "[Company Name]" should succeed.
47
+ // However, on the unpatched codebase, it throws a BatchValidationError (Target text not found).
48
+ const res = engine.process_batch([
49
+ {
50
+ type: "modify",
51
+ target_text: "[Company Name]",
52
+ new_text: "Google LLC",
53
+ } as any,
54
+ ]);
55
+
56
+ expect(res.edits_applied).toBe(1);
57
+ expect(res.edits_skipped).toBe(0);
58
+ });
59
+
60
+ it("Bug 2: editing across a foreign author's insertion boundary is refused (provenance-safe)", async () => {
61
+ const doc = await createTestDocument();
62
+ const xmlDoc = doc.element.ownerDocument!;
63
+
64
+ const p = addParagraph(doc, "The party shall provide ");
65
+ const ins = xmlDoc.createElement("w:ins");
66
+ ins.setAttribute("w:id", "123");
67
+ ins.setAttribute("w:author", "Supplier's Counsel");
68
+ const r = xmlDoc.createElement("w:r");
69
+ const t = xmlDoc.createElement("w:t");
70
+ t.textContent = "written notice";
71
+ r.appendChild(t);
72
+ ins.appendChild(r);
73
+ p.appendChild(ins);
74
+
75
+ const suffixRun = xmlDoc.createElement("w:r");
76
+ const suffixText = xmlDoc.createElement("w:t");
77
+ suffixText.textContent = " within 30 days.";
78
+ suffixRun.appendChild(suffixText);
79
+ p.appendChild(suffixRun);
80
+
81
+ const engine = new RedlineEngine(doc, "Reviewer");
82
+
83
+ // The target STRADDLES Supplier's Counsel's pending <w:ins> boundary
84
+ // (foreign-inserted "written notice" + plain " within 30 days."). Canonical
85
+ // behavior is to REFUSE rather than silently lift the foreign insertion into
86
+ // committed text (which would launder another author's pending proposal and
87
+ // erase their provenance). The agent must accept that change first, or scope
88
+ // the edit within / outside the insertion. (Matches the Python engine.)
89
+ const edit = {
90
+ type: "modify",
91
+ target_text: "written notice within 30 days.",
92
+ new_text: "notice within 15 business days.",
93
+ };
94
+ expect(() => engine.process_batch([edit as any], false)).toThrow(
95
+ /active insertion from another author/,
96
+ );
97
+ });
98
+
99
+ it("Bug 3: Table Layout Ambiguity and Markdown Loss of Fidelity", async () => {
100
+ const doc = await createTestDocument();
101
+
102
+ // Create a table with 2 rows, 4 columns
103
+ const tbl = addTable(doc, 2, 4);
104
+
105
+ // Set cell text to match identical structure: Date | | |
106
+ setCellText(tbl, 0, 0, "Date");
107
+ setCellText(tbl, 0, 1, "");
108
+ setCellText(tbl, 0, 2, "");
109
+ setCellText(tbl, 0, 3, "");
110
+
111
+ setCellText(tbl, 1, 0, "Date");
112
+ setCellText(tbl, 1, 1, "");
113
+ setCellText(tbl, 1, 2, "");
114
+ setCellText(tbl, 1, 3, "");
115
+
116
+ const buf = await doc.save();
117
+ const midDoc = await DocumentObject.load(buf);
118
+ const engine = new RedlineEngine(midDoc);
119
+
120
+ // Target one of the empty rows to apply a date.
121
+ const edit = {
122
+ type: "modify",
123
+ target_text: "Date | | | ",
124
+ new_text: "Date | June 29, 2026 | | ",
125
+ };
126
+
127
+ // Ideally, the system should allow distinct targeting of cells (e.g. preserving grid coordinates).
128
+ // But due to the coordinate-flattening design flaw, it throws "Ambiguous match".
129
+ const res = engine.process_batch([edit as any]);
130
+ expect(res.edits_applied).toBe(1);
131
+ });
132
+ });