@adeu/core 1.26.0 → 1.28.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.
@@ -23,7 +23,9 @@ describe('Ingestion Engine (Node.js Port)', () => {
23
23
  expect(markdown.length).toBeGreaterThan(0);
24
24
 
25
25
  // Assert exact parity with the Python engine's CriticMarkup generation
26
- expect(markdown).toBe('This is the {--initial --}{++golden ++}{>>[Chg:3 delete] Mikko Korpela\n[Chg:4 insert] Mikko Korpela\n[Com:0] Mikko Korpela @ 2026-01-23T07:25:00Z: Start of comment thread\n[Com:1] Mikko Korpela @ 2026-01-23T07:25:00Z: Second comment\n[Com:2] Mikko Korpela @ 2026-01-23T07:26:00Z: Third comment in the thread<<}document');
26
+ // The del+ins pair of one modification is annotated as a resolution
27
+ // group (QA 2026-07-19 ADEU-QA-004).
28
+ expect(markdown).toBe('This is the {--initial --}{++golden ++}{>>[Chg:3 delete] Mikko Korpela (pairs with Chg:4)\n[Chg:4 insert] Mikko Korpela (pairs with Chg:3)\n[Com:0] Mikko Korpela @ 2026-01-23T07:25:00Z: Start of comment thread\n[Com:1] Mikko Korpela @ 2026-01-23T07:25:00Z: Second comment\n[Com:2] Mikko Korpela @ 2026-01-23T07:26:00Z: Third comment in the thread<<}document');
27
29
  });
28
30
 
29
31
  it('should execute in cleanView mode without failing', async () => {
package/src/ingest.ts CHANGED
@@ -2,6 +2,7 @@ import { DocumentObject } from "./docx/bridge.js";
2
2
  import { Paragraph, Table, Run, DocxEvent } from "./docx/primitives.js";
3
3
  import {
4
4
  _get_style_cache,
5
+ compute_change_pair_map,
5
6
  get_paragraph_prefix,
6
7
  is_heading_paragraph,
7
8
  is_native_heading,
@@ -258,7 +259,8 @@ export function extract_table(
258
259
  | undefined;
259
260
  const paraId = firstP ? firstP.getAttribute("w14:paraId") : null;
260
261
  if (paraId) {
261
- const anchor = `{#cell:${paraId}}`;
262
+ const space_pad = cell_content ? " " : "";
263
+ const anchor = `${space_pad}{#cell:${paraId}}`;
262
264
  cell_content = cell_content + anchor;
263
265
  }
264
266
  }
@@ -603,13 +605,22 @@ function _build_merged_meta_block(
603
605
  const comment_lines: string[] = [];
604
606
  const seen_sigs = new Set<string>();
605
607
 
608
+ // Ids of one resolution group (a replacement's contiguous same-author
609
+ // del+ins pair) must not read as independently resolvable — either side
610
+ // resolves the whole group (QA 2026-07-19 ADEU-QA-004).
611
+ const pair_map = compute_change_pair_map(states_list);
612
+ const pairSuffix = (uid: string): string =>
613
+ pair_map[uid] ? ` (pairs with ${pair_map[uid]})` : "";
614
+
606
615
  for (const [ins_map, del_map, comments_set, fmt_map] of states_list) {
607
616
  for (const [uid, meta] of Object.entries(
608
617
  ins_map as Record<string, DocxEvent>,
609
618
  )) {
610
619
  const sig = `Chg:${uid}`;
611
620
  if (!seen_sigs.has(sig)) {
612
- change_lines.push(`[${sig} insert] ${meta.author || "Unknown"}`);
621
+ change_lines.push(
622
+ `[${sig} insert] ${meta.author || "Unknown"}${pairSuffix(uid)}`,
623
+ );
613
624
  seen_sigs.add(sig);
614
625
  }
615
626
  }
@@ -618,7 +629,9 @@ function _build_merged_meta_block(
618
629
  )) {
619
630
  const sig = `Chg:${uid}`;
620
631
  if (!seen_sigs.has(sig)) {
621
- change_lines.push(`[${sig} delete] ${meta.author || "Unknown"}`);
632
+ change_lines.push(
633
+ `[${sig} delete] ${meta.author || "Unknown"}${pairSuffix(uid)}`,
634
+ );
622
635
  seen_sigs.add(sig);
623
636
  }
624
637
  }
package/src/mapper.ts CHANGED
@@ -5,6 +5,7 @@ import { extract_comments_data } from "./comments.js";
5
5
  import { RegexTimeoutError, userFindAllMatches, userSearch } from "./utils/safe-regex.js";
6
6
  import {
7
7
  _get_style_cache,
8
+ compute_change_pair_map,
8
9
  get_paragraph_prefix,
9
10
  get_run_style_markers,
10
11
  get_run_text,
@@ -357,6 +358,11 @@ export class DocumentMapper {
357
358
  // token offset so resolution targets THIS cell, not a neighbour.
358
359
  const cellPara = new Paragraph(firstP, cell);
359
360
  this._add_virtual_text("", current, cellPara);
361
+ const has_content = current > cell_start;
362
+ if (has_content) {
363
+ this._add_virtual_text(" ", current, cellPara);
364
+ current += 1;
365
+ }
360
366
  const anchor = `{#cell:${paraId}}`;
361
367
  this._add_virtual_text(anchor, current, cellPara);
362
368
  current += anchor.length;
@@ -810,6 +816,13 @@ export class DocumentMapper {
810
816
  const comment_lines: string[] = [];
811
817
  const seen_sigs = new Set<string>();
812
818
 
819
+ // Must render EXACTLY as ingest's _build_merged_meta_block (Virtual Text
820
+ // contract), including the resolution-group annotation
821
+ // (QA 2026-07-19 ADEU-QA-004).
822
+ const pair_map = compute_change_pair_map(states_list);
823
+ const pairSuffix = (uid: string): string =>
824
+ pair_map[uid] ? ` (pairs with ${pair_map[uid]})` : "";
825
+
813
826
  for (const [ins_map, del_map, comments_set, fmt_map] of states_list) {
814
827
  for (const [uid, meta] of Object.entries(
815
828
  ins_map as Record<string, DocxEvent>,
@@ -817,7 +830,7 @@ export class DocumentMapper {
817
830
  const sig = `Chg:${uid}`;
818
831
  if (!seen_sigs.has(sig)) {
819
832
  const auth = meta.author || "Unknown";
820
- change_lines.push(`[${sig} insert] ${auth}`);
833
+ change_lines.push(`[${sig} insert] ${auth}${pairSuffix(uid)}`);
821
834
  seen_sigs.add(sig);
822
835
  }
823
836
  }
@@ -827,7 +840,7 @@ export class DocumentMapper {
827
840
  const sig = `Chg:${uid}`;
828
841
  if (!seen_sigs.has(sig)) {
829
842
  const auth = meta.author || "Unknown";
830
- change_lines.push(`[${sig} delete] ${auth}`);
843
+ change_lines.push(`[${sig} delete] ${auth}${pairSuffix(uid)}`);
831
844
  seen_sigs.add(sig);
832
845
  }
833
846
  }
@@ -1000,6 +1013,40 @@ export class DocumentMapper {
1000
1013
  return results;
1001
1014
  }
1002
1015
 
1016
+ /**
1017
+ * True when no run-backed span overlaps [start, start+length): the range
1018
+ * covers only virtual projection text — meta bubbles (change/comment
1019
+ * headers, timestamps), style markers, list prefixes. Such text does not
1020
+ * exist in the document, so it can neither satisfy a match nor count
1021
+ * toward ambiguity (QA 2026-07-19 ADEU-QA-002 C): an edit targeting "4"
1022
+ * used to be rejected as "appears 8 times" because a comment bubble's
1023
+ * timestamp matched.
1024
+ *
1025
+ * Anchor tokens ({#Bookmark}, {#cell:paraId}) are the exception: they are
1026
+ * deliberate virtual TARGETING surfaces (empty-cell writes, bookmark
1027
+ * anchors) and must stay matchable.
1028
+ */
1029
+ public range_is_virtual_only(start: number, length: number): boolean {
1030
+ const end = start + length;
1031
+ const overlapping = this.spans.filter(
1032
+ (s) => s.end > start && s.start < end,
1033
+ );
1034
+ if (overlapping.some((s) => s.run !== null)) return false;
1035
+ return !overlapping.some(
1036
+ (s) => s.run === null && s.text.startsWith("{#"),
1037
+ );
1038
+ }
1039
+
1040
+ /** Filters find_all_match_indices output down to matches that touch at
1041
+ * least one run-backed span. See range_is_virtual_only. */
1042
+ public drop_virtual_only_matches(
1043
+ matches: [number, number][],
1044
+ ): [number, number][] {
1045
+ return matches.filter(
1046
+ ([start, length]) => !this.range_is_virtual_only(start, length),
1047
+ );
1048
+ }
1049
+
1003
1050
  public find_match_index(
1004
1051
  target_text: string,
1005
1052
  is_regex: boolean = false,
@@ -1011,19 +1058,33 @@ export class DocumentMapper {
1011
1058
  // invalid-pattern errors mean "no match" here.
1012
1059
  try {
1013
1060
  const match = userSearch(target_text, this.full_text);
1014
- if (match) return [match.start, match.end - match.start];
1061
+ if (
1062
+ match &&
1063
+ !this.range_is_virtual_only(match.start, match.end - match.start)
1064
+ ) {
1065
+ return [match.start, match.end - match.start];
1066
+ }
1015
1067
  } catch (e) {
1016
1068
  if (e instanceof RegexTimeoutError) throw e;
1017
1069
  }
1018
1070
  return [-1, 0];
1019
1071
  }
1020
1072
 
1021
- let start_idx = this.full_text.indexOf(target_text);
1022
- if (start_idx !== -1) return [start_idx, target_text.length];
1073
+ // Exact tier: skip occurrences that cover only virtual projection text
1074
+ // (meta bubbles, markers) they are not document text (ADEU-QA-002 C).
1075
+ let from = 0;
1076
+ while (true) {
1077
+ const idx = this.full_text.indexOf(target_text, from);
1078
+ if (idx === -1) break;
1079
+ if (!this.range_is_virtual_only(idx, target_text.length)) {
1080
+ return [idx, target_text.length];
1081
+ }
1082
+ from = idx + 1;
1083
+ }
1023
1084
 
1024
1085
  const norm_full = this._replace_smart_quotes(this.full_text);
1025
1086
  const norm_target = this._replace_smart_quotes(target_text);
1026
- start_idx = norm_full.indexOf(norm_target);
1087
+ let start_idx = norm_full.indexOf(norm_target);
1027
1088
  if (start_idx !== -1) return [start_idx, target_text.length];
1028
1089
 
1029
1090
  const stripped_target = this._strip_markdown_formatting(target_text);
@@ -1039,9 +1100,13 @@ export class DocumentMapper {
1039
1100
  if (plain_first.length > 0) return plain_first[0];
1040
1101
 
1041
1102
  try {
1042
- const pattern = new RegExp(this._make_fuzzy_regex(target_text));
1043
- const match = pattern.exec(this.full_text);
1044
- if (match) return [match.index, match[0].length];
1103
+ const pattern = new RegExp(this._make_fuzzy_regex(target_text), "g");
1104
+ for (const match of this.full_text.matchAll(pattern)) {
1105
+ // Virtual-only ranges are projection chrome, not document text.
1106
+ if (!this.range_is_virtual_only(match.index!, match[0].length)) {
1107
+ return [match.index!, match[0].length];
1108
+ }
1109
+ }
1045
1110
  } catch (e) {}
1046
1111
 
1047
1112
  return [-1, 0];
@@ -1172,8 +1237,16 @@ export class DocumentMapper {
1172
1237
  const first_real_span = real_spans[0];
1173
1238
  let start_split_adjustment = 0;
1174
1239
 
1240
+ // A range may START on a virtual span (word-diff hunks absorb a style
1241
+ // marker adjacent to real changes, e.g. the `**` closing a bold run).
1242
+ // Virtual characters have no physical width: clamp to the first real
1243
+ // span's start or the subtraction goes negative and the split point
1244
+ // lands INSIDE the preceding run's kept text — the "**The Suppli**"
1245
+ // partial-word artifact (QA 2026-07-19 v8 F-04).
1175
1246
  const local_start =
1176
- start_idx - first_real_span.start + (first_real_span.run_offset || 0);
1247
+ Math.max(start_idx, first_real_span.start) -
1248
+ first_real_span.start +
1249
+ (first_real_span.run_offset || 0);
1177
1250
  if (local_start > 0) {
1178
1251
  const split_source = working_runs[0];
1179
1252
  const [, right_run] = this._split_run_at_index(
@@ -0,0 +1,224 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { createTestDocument, addParagraph, addTable, setCellText } from "./test-utils.js";
3
+ import { _extractTextFromDoc, extractTextFromBuffer } from "./ingest.js";
4
+ import { RedlineEngine } from "./engine.js";
5
+ import { extract_outline } from "./outline.js";
6
+ import { paginate } from "./pagination.js";
7
+ import { finalize_document } from "./sanitize/core.js";
8
+ import { findChild, findAllDescendants, QN_W_R, QN_W_RPR, QN_W_B, QN_W_VAL, QN_W_T } from "./utils/docx.js";
9
+
10
+ describe("Adeu MCP QA Report - Issue 1: Markdown headings in new_text", () => {
11
+ it("TC 1.1: single modify to ### heading changes style and is visible in outline", async () => {
12
+ const doc = await createTestDocument();
13
+ addParagraph(doc, "This is a normal paragraph.");
14
+
15
+ const engine = new RedlineEngine(doc);
16
+ const res = engine.process_batch([
17
+ {
18
+ type: "modify",
19
+ target_text: "This is a normal paragraph.",
20
+ new_text: "### Heading Only Test",
21
+ },
22
+ ]);
23
+
24
+ expect(res.edits_applied).toBe(1);
25
+
26
+ // Re-extract outline
27
+ const extract_res = _extractTextFromDoc(doc, false, false, true) as {
28
+ text: string;
29
+ paragraph_offsets: Map<any, [number, number]>;
30
+ };
31
+ const pages = paginate(extract_res.text, "");
32
+ const nodes = extract_outline(
33
+ doc,
34
+ extract_res.text,
35
+ pages.body_pages,
36
+ pages.body_page_offsets,
37
+ extract_res.paragraph_offsets as any,
38
+ );
39
+
40
+ // The heading must be present in the outline
41
+ const testNode = nodes.find((n) => n.text.includes("Heading Only Test"));
42
+ expect(testNode).toBeDefined();
43
+ expect(testNode!.level).toBe(3);
44
+ });
45
+
46
+ it("TC 1.2: multi-paragraph insert with heading does not leak ### prefix to other paragraphs", async () => {
47
+ const doc = await createTestDocument();
48
+ addParagraph(doc, "Replace me.");
49
+
50
+ const engine = new RedlineEngine(doc);
51
+ const res = engine.process_batch([
52
+ {
53
+ type: "modify",
54
+ target_text: "Replace me.",
55
+ new_text: "### Title\n\nBody with **bold** and _italic_.\n\nSecond paragraph.",
56
+ },
57
+ ]);
58
+
59
+ expect(res.edits_applied).toBe(1);
60
+
61
+ const buf = await doc.save();
62
+ const cleanText = await extractTextFromBuffer(buf, true);
63
+
64
+ // Subsequent paragraphs must not have literal "###" prepended
65
+ expect(cleanText).toContain("Body with **bold** and _italic_.");
66
+ expect(cleanText).toContain("Second paragraph.");
67
+ expect(cleanText).not.toContain("### Body with");
68
+ expect(cleanText).not.toContain("### Second");
69
+ });
70
+ });
71
+
72
+ describe("Adeu MCP QA Report - Issue 2: Bold/large-font table cells misclassified as headings", () => {
73
+ it("TC 2.1: bold table cell with uppercase/numeric text is NOT classified as a heading", async () => {
74
+ const doc = await createTestDocument();
75
+ const tbl = addTable(doc, 1, 1);
76
+ setCellText(tbl, 0, 0, "$ 88,136 $ 72,361 $ 72,738");
77
+
78
+ // Make the run inside the cell bold
79
+ const p = tbl.getElementsByTagName("w:p")[0];
80
+ const r = p.getElementsByTagName("w:r")[0];
81
+ const xmlDoc = doc.element.ownerDocument!;
82
+ const rPr = xmlDoc.createElement("w:rPr");
83
+ const b = xmlDoc.createElement("w:b");
84
+ rPr.appendChild(b);
85
+ r.insertBefore(rPr, r.firstChild);
86
+
87
+ // Re-extract outline
88
+ const extract_res = _extractTextFromDoc(doc, false, false, true) as {
89
+ text: string;
90
+ paragraph_offsets: Map<any, [number, number]>;
91
+ };
92
+ const pages = paginate(extract_res.text, "");
93
+ const nodes = extract_outline(
94
+ doc,
95
+ extract_res.text,
96
+ pages.body_pages,
97
+ pages.body_page_offsets,
98
+ extract_res.paragraph_offsets as any,
99
+ );
100
+
101
+ // The outline should be empty or should NOT contain the table cell text as a heading
102
+ const fakeHeading = nodes.find((n) => n.text.includes("$ 88,136"));
103
+ expect(fakeHeading).toBeUndefined();
104
+ });
105
+ });
106
+
107
+ describe("Adeu MCP QA Report - Issue 3: insert_row/delete_row with anchor-only target", () => {
108
+ it("TC 3.1: insert_row succeeds when targeted with a {#cell:paraId} anchor alone", async () => {
109
+ const doc = await createTestDocument();
110
+ const tbl = addTable(doc, 1, 2);
111
+ setCellText(tbl, 0, 0, "A1");
112
+ setCellText(tbl, 0, 1, "A2");
113
+
114
+ // Add w14:paraId to the paragraph of cell 0 to act as our stable anchor
115
+ const rows = Array.from(tbl.childNodes).filter((n) => (n as Element).tagName === "w:tr") as Element[];
116
+ const cells = Array.from(rows[0].childNodes).filter((n) => (n as Element).tagName === "w:tc") as Element[];
117
+ const p = cells[0].getElementsByTagName("w:p")[0];
118
+ p.setAttribute("w14:paraId", "DEADBEEF");
119
+
120
+ const engine = new RedlineEngine(doc);
121
+ const res = engine.process_batch([
122
+ {
123
+ type: "insert_row",
124
+ target_text: "{#cell:DEADBEEF}",
125
+ cells: ["B1", "B2"],
126
+ position: "below",
127
+ } as any,
128
+ ]);
129
+
130
+ expect(res.edits_applied).toBe(1);
131
+ });
132
+
133
+ it("TC 3.2: delete_row succeeds when targeted with a {#cell:paraId} anchor alone", async () => {
134
+ const doc = await createTestDocument();
135
+ const tbl = addTable(doc, 2, 2);
136
+ setCellText(tbl, 0, 0, "A1");
137
+ setCellText(tbl, 0, 1, "A2");
138
+ setCellText(tbl, 1, 0, "B1");
139
+ setCellText(tbl, 1, 1, "B2");
140
+
141
+ // Add w14:paraId to the paragraph of cell (0,0) to act as our stable anchor
142
+ const rows = Array.from(tbl.childNodes).filter((n) => (n as Element).tagName === "w:tr") as Element[];
143
+ const cells0 = Array.from(rows[0].childNodes).filter((n) => (n as Element).tagName === "w:tc") as Element[];
144
+ const p = cells0[0].getElementsByTagName("w:p")[0];
145
+ p.setAttribute("w14:paraId", "DEADBEEF");
146
+
147
+ const engine = new RedlineEngine(doc);
148
+ const res = engine.process_batch([
149
+ {
150
+ type: "delete_row",
151
+ target_text: "{#cell:DEADBEEF}",
152
+ } as any,
153
+ ]);
154
+
155
+ expect(res.edits_applied).toBe(1);
156
+ });
157
+ });
158
+
159
+ describe("Adeu MCP QA Report - Issue 5: Empty-cell fill spacing", () => {
160
+ it("TC 5.1: modifying cell anchor next to a label preserves or inserts a separator space", async () => {
161
+ const doc = await createTestDocument();
162
+ const tbl = addTable(doc, 1, 1);
163
+ const rows = Array.from(tbl.childNodes).filter((n) => (n as Element).tagName === "w:tr") as Element[];
164
+ const cells = Array.from(rows[0].childNodes).filter((n) => (n as Element).tagName === "w:tc") as Element[];
165
+ const tc = cells[0];
166
+
167
+ // Clear and construct a paragraph with text "Nimi" and paraId "DEADBEEF"
168
+ while (tc.firstChild) tc.removeChild(tc.firstChild);
169
+ const xmlDoc = doc.element.ownerDocument!;
170
+ const p = xmlDoc.createElement("w:p");
171
+ p.setAttribute("w14:paraId", "DEADBEEF");
172
+ const r = xmlDoc.createElement("w:r");
173
+ const t = xmlDoc.createElement("w:t");
174
+ t.textContent = "Nimi";
175
+ r.appendChild(t);
176
+ p.appendChild(r);
177
+ tc.appendChild(p);
178
+
179
+ const engine = new RedlineEngine(doc);
180
+ const res = engine.process_batch([
181
+ {
182
+ type: "modify",
183
+ target_text: "{#cell:DEADBEEF}",
184
+ new_text: "Testi Testinen",
185
+ } as any,
186
+ ]);
187
+
188
+ expect(res.edits_applied).toBe(1);
189
+
190
+ (engine as any).accept_all_revisions();
191
+ const cleanText = await extractTextFromBuffer(await doc.save(), true);
192
+ // It should have some separator, e.g. "Nimi Testi Testinen" instead of "NimiTesti Testinen"
193
+ expect(cleanText).not.toContain("NimiTesti Testinen");
194
+ });
195
+ });
196
+
197
+ describe("Adeu MCP QA Report - Issue 7: finalize_document keep-markup comments section label", () => {
198
+ it("TC 7.1: keep-markup with open comments does NOT label them as COMMENTS (stripped)", async () => {
199
+ const doc = await createTestDocument();
200
+ addParagraph(doc, "This is a normal paragraph.");
201
+
202
+ const engine = new RedlineEngine(doc);
203
+ const res = engine.process_batch([
204
+ {
205
+ type: "modify",
206
+ target_text: "This is a normal paragraph.",
207
+ new_text: "This is a normal paragraph.",
208
+ comment: "This is a comment",
209
+ } as any,
210
+ ]);
211
+ expect(res.edits_applied).toBe(1);
212
+
213
+ const finalizeRes = await finalize_document(doc, {
214
+ filename: "test.docx",
215
+ sanitize_mode: "keep-markup",
216
+ });
217
+
218
+ const report = finalizeRes.reportText;
219
+
220
+ // The report must NOT contain the contradictory "COMMENTS (stripped)" header
221
+ // when comments are actually kept (0 comments removed, 1 comment kept).
222
+ expect(report).not.toContain("COMMENTS (stripped)");
223
+ });
224
+ });