@adeu/core 1.26.0 → 1.27.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@adeu/core",
3
- "version": "1.26.0",
3
+ "version": "1.27.0",
4
4
  "description": "",
5
5
  "main": "./dist/index.js",
6
6
  "types": "./dist/index.d.ts",
package/src/engine.ts CHANGED
@@ -1423,6 +1423,12 @@ export class RedlineEngine {
1423
1423
  // authoritative: strip inherited bold/italic from the anchor style
1424
1424
  // (QA 2026-07-19 F-02).
1425
1425
  suppress_emphasis: boolean = false,
1426
+ // True when the caller will attach the insertion BEFORE the anchor
1427
+ // (paragraph-start insertions): the anchor itself then belongs to the
1428
+ // relocating suffix (hunt-profile counterexample, 2026-07-19 —
1429
+ // "00." + insert "0.\n\n0 " must read "0.\n\n0 00.", never
1430
+ // "0.00.\n\n0 "). Mirrors the Python engine.
1431
+ insert_before: boolean = false,
1426
1432
  ): {
1427
1433
  first_node: Element | null;
1428
1434
  last_p: Element | null;
@@ -1459,12 +1465,21 @@ export class RedlineEngine {
1459
1465
  // DOM, and the insertion lands immediately after it, so its following
1460
1466
  // child-of-paragraph siblings are exactly the suffix.
1461
1467
  const suffix_nodes: Element[] = [];
1462
- const pos_source =
1468
+ const relocatable = new Set(["w:r", "w:ins", "w:del"]);
1469
+ // insert_before with a RUN anchor: the insertion precedes the anchor, so
1470
+ // the anchor run itself is part of the suffix. (The explicit
1471
+ // positional_anchor_el is only passed by flows that insert AFTER it.)
1472
+ const pos_from_positional =
1463
1473
  positional_anchor_el && positional_anchor_el.parentNode
1464
1474
  ? positional_anchor_el
1465
- : anchor_run !== null && anchor_run._element.parentNode
1466
- ? anchor_run._element
1467
- : null;
1475
+ : null;
1476
+ const pos_from_anchor_run =
1477
+ anchor_run !== null && anchor_run._element.parentNode
1478
+ ? anchor_run._element
1479
+ : null;
1480
+ const pos_source = pos_from_positional ?? pos_from_anchor_run;
1481
+ const suffix_includes_anchor =
1482
+ insert_before && pos_from_positional === null && pos_from_anchor_run !== null;
1468
1483
  if (current_p !== null && pos_source !== null) {
1469
1484
  let pos_anchor: Element | null = pos_source;
1470
1485
  while (pos_anchor && pos_anchor.parentNode !== current_p) {
@@ -1475,8 +1490,9 @@ export class RedlineEngine {
1475
1490
  }
1476
1491
  }
1477
1492
  if (pos_anchor) {
1478
- const relocatable = new Set(["w:r", "w:ins", "w:del"]);
1479
- let nxt = pos_anchor.nextSibling;
1493
+ let nxt: Node | null = suffix_includes_anchor
1494
+ ? pos_anchor
1495
+ : pos_anchor.nextSibling;
1480
1496
  while (nxt) {
1481
1497
  if (nxt.nodeType === 1 && relocatable.has((nxt as Element).tagName)) {
1482
1498
  suffix_nodes.push(nxt as Element);
@@ -1484,6 +1500,20 @@ export class RedlineEngine {
1484
1500
  nxt = nxt.nextSibling;
1485
1501
  }
1486
1502
  }
1503
+ } else if (current_p !== null && insert_before) {
1504
+ // No attached anchor run at all (paragraph-anchored insertion at
1505
+ // paragraph START): everything in the host paragraph follows the
1506
+ // insertion point, so it all relocates (mirrors the Python engine).
1507
+ let child = current_p.firstChild;
1508
+ while (child) {
1509
+ if (
1510
+ child.nodeType === 1 &&
1511
+ relocatable.has((child as Element).tagName)
1512
+ ) {
1513
+ suffix_nodes.push(child as Element);
1514
+ }
1515
+ child = child.nextSibling;
1516
+ }
1487
1517
  }
1488
1518
 
1489
1519
  // Drop the trailing empty line ONLY when there is no suffix to relocate.
@@ -2523,6 +2553,60 @@ export class RedlineEngine {
2523
2553
 
2524
2554
  public validate_review_actions(actions: any[]): string[] {
2525
2555
  const errors: string[] = [];
2556
+
2557
+ // Document-context-free shape checks (QA 2026-07-19 v8 F-07), mirroring
2558
+ // Python's validate_review_action_batch: blank replies render as empty
2559
+ // Word comment bubbles; a duplicated or conflicting accept/reject on one
2560
+ // target_id either double-counts as "applied" or contradicts itself.
2561
+ // Distinct IDs one action resolves as a group (a modification's del+ins
2562
+ // pair) stay legitimate, as do DIFFERENT replies to the same comment.
2563
+ const seen_resolutions = new Map<string, [number, string]>();
2564
+ const seen_replies = new Set<string>();
2565
+ for (let i = 0; i < actions.length; i++) {
2566
+ const action = actions[i];
2567
+ const type = action.type;
2568
+ const target_id = action.target_id ?? "";
2569
+ if (type === "reply") {
2570
+ if (!String(action.text ?? "").trim()) {
2571
+ errors.push(
2572
+ `- Action ${i + 1} Failed: reply text for ${target_id} is empty or ` +
2573
+ `whitespace-only. Word would show a blank comment bubble — provide the ` +
2574
+ `reply content in 'text'.`,
2575
+ );
2576
+ continue;
2577
+ }
2578
+ const reply_key = `${target_id}${String(action.text).trim()}`;
2579
+ if (seen_replies.has(reply_key)) {
2580
+ errors.push(
2581
+ `- Action ${i + 1} Failed: duplicate reply — this batch already replies to ` +
2582
+ `${target_id} with the same text. Remove the duplicate action.`,
2583
+ );
2584
+ }
2585
+ seen_replies.add(reply_key);
2586
+ } else if (type === "accept" || type === "reject") {
2587
+ const prior = seen_resolutions.get(target_id);
2588
+ if (prior !== undefined) {
2589
+ const [first_idx, first_type] = prior;
2590
+ if (first_type === type) {
2591
+ errors.push(
2592
+ `- Action ${i + 1} Failed: duplicate action — Action ${first_idx + 1} in this ` +
2593
+ `batch already applies '${type}' to ${target_id}. A change can only be ` +
2594
+ `resolved once; remove the duplicate action.`,
2595
+ );
2596
+ } else {
2597
+ errors.push(
2598
+ `- Action ${i + 1} Failed: conflicting actions — Action ${first_idx + 1} in ` +
2599
+ `this batch applies '${first_type}' to ${target_id}, but this action applies ` +
2600
+ `'${type}'. Decide the outcome and keep exactly one of them.`,
2601
+ );
2602
+ }
2603
+ } else {
2604
+ seen_resolutions.set(target_id, [i, type]);
2605
+ }
2606
+ }
2607
+ }
2608
+ if (errors.length > 0) return errors;
2609
+
2526
2610
  for (let i = 0; i < actions.length; i++) {
2527
2611
  const action = actions[i];
2528
2612
  const type = action.type;
@@ -4161,6 +4245,9 @@ export class RedlineEngine {
4161
4245
  ins_id!,
4162
4246
  null,
4163
4247
  suppress_emphasis,
4248
+ // Paragraph-start insertions attach BEFORE the anchor (see
4249
+ // before_anchor below): the suffix relocation must know.
4250
+ start_idx === 0,
4164
4251
  );
4165
4252
 
4166
4253
  if (!result.first_node) return false;
package/src/mapper.ts CHANGED
@@ -1172,8 +1172,16 @@ export class DocumentMapper {
1172
1172
  const first_real_span = real_spans[0];
1173
1173
  let start_split_adjustment = 0;
1174
1174
 
1175
+ // A range may START on a virtual span (word-diff hunks absorb a style
1176
+ // marker adjacent to real changes, e.g. the `**` closing a bold run).
1177
+ // Virtual characters have no physical width: clamp to the first real
1178
+ // span's start or the subtraction goes negative and the split point
1179
+ // lands INSIDE the preceding run's kept text — the "**The Suppli**"
1180
+ // partial-word artifact (QA 2026-07-19 v8 F-04).
1175
1181
  const local_start =
1176
- start_idx - first_real_span.start + (first_real_span.run_offset || 0);
1182
+ Math.max(start_idx, first_real_span.start) -
1183
+ first_real_span.start +
1184
+ (first_real_span.run_offset || 0);
1177
1185
  if (local_start > 0) {
1178
1186
  const split_source = working_runs[0];
1179
1187
  const [, right_run] = this._split_run_at_index(
@@ -0,0 +1,265 @@
1
+ // FILE: node/packages/core/src/repro_qa_report_v8.test.ts
2
+ /**
3
+ * Node-engine repro tests for the 2026-07-19 black-box QA and UX report on
4
+ * 1.26.0 (adeu 1.26.0+0741eaf). Mirrors python/tests/test_repro_qa_report_v8.py
5
+ * for the findings that live in the shared core engine:
6
+ *
7
+ * F-04 replacing a full sentence that crosses bold/italic formatting runs
8
+ * leaves a partial word bold (`**The Suppli** must perform ...`):
9
+ * the word-diff hunk absorbs the closing `**` marker, the resolved
10
+ * range starts on a virtual span, and the run-local split offset
11
+ * underflows into the preceding run's kept text
12
+ * F-07 review-action validation permits blank replies and duplicate /
13
+ * conflicting accept-reject pairs on one target_id
14
+ *
15
+ * Every test fails against the commit preceding its fix.
16
+ */
17
+
18
+ import { describe, it, expect } from "vitest";
19
+ import { createTestDocument, addParagraph } from "./test-utils.js";
20
+ import { DocumentObject } from "./docx/bridge.js";
21
+ import { RedlineEngine, BatchValidationError } from "./engine.js";
22
+ import { _extractTextFromDoc, extractTextFromBuffer } from "./ingest.js";
23
+
24
+ function addStyledRun(
25
+ p: Element,
26
+ text: string,
27
+ style: { bold?: boolean; italic?: boolean; underline?: boolean } = {},
28
+ ): Element {
29
+ const xmlDoc = p.ownerDocument!;
30
+ const r = xmlDoc.createElement("w:r");
31
+ if (style.bold || style.italic || style.underline) {
32
+ const rPr = xmlDoc.createElement("w:rPr");
33
+ if (style.bold) rPr.appendChild(xmlDoc.createElement("w:b"));
34
+ if (style.italic) rPr.appendChild(xmlDoc.createElement("w:i"));
35
+ if (style.underline) {
36
+ const u = xmlDoc.createElement("w:u");
37
+ u.setAttribute("w:val", "single");
38
+ rPr.appendChild(u);
39
+ }
40
+ r.appendChild(rPr);
41
+ }
42
+ const t = xmlDoc.createElement("w:t");
43
+ t.textContent = text;
44
+ if (text !== text.trim()) t.setAttribute("xml:space", "preserve");
45
+ r.appendChild(t);
46
+ p.appendChild(r);
47
+ return r;
48
+ }
49
+
50
+ function addEmptyParagraph(doc: DocumentObject): Element {
51
+ const xmlDoc = doc.element.ownerDocument!;
52
+ const p = xmlDoc.createElement("w:p");
53
+ doc.element.appendChild(p);
54
+ return p;
55
+ }
56
+
57
+ /** The QA report's F-04 fixture: bold "The Supplier ", italic
58
+ * "shall provide ", underlined remainder — one sentence, three runs. */
59
+ async function buildCrossRunDoc(): Promise<DocumentObject> {
60
+ const doc = await createTestDocument();
61
+ const p = addEmptyParagraph(doc);
62
+ addStyledRun(p, "The Supplier ", { bold: true });
63
+ addStyledRun(p, "shall provide ", { italic: true });
64
+ addStyledRun(p, "the Services with reasonable skill and care.", {
65
+ underline: true,
66
+ });
67
+ return doc;
68
+ }
69
+
70
+ function cleanText(doc: DocumentObject): string {
71
+ return _extractTextFromDoc(doc, {
72
+ cleanView: true,
73
+ includeAppendix: false,
74
+ }) as string;
75
+ }
76
+
77
+ /** A document carrying exactly one tracked modification (Chg:1 + Chg:2). */
78
+ async function buildTrackedChangeDoc(): Promise<DocumentObject> {
79
+ const doc = await createTestDocument();
80
+ addParagraph(doc, "Payment is due in 30 days.");
81
+ addParagraph(doc, "Second paragraph here.");
82
+ const engine = new RedlineEngine(doc);
83
+ engine.apply_edits([
84
+ { type: "modify", target_text: "30 days", new_text: "60 days" },
85
+ ]);
86
+ return DocumentObject.load(await doc.save());
87
+ }
88
+
89
+ /** A document carrying exactly one comment; returns [doc, "Com:<id>"]. */
90
+ async function buildCommentDoc(): Promise<[DocumentObject, string]> {
91
+ const doc = await createTestDocument();
92
+ addParagraph(doc, "Alpha beta gamma.");
93
+ addParagraph(doc, "Delta epsilon.");
94
+ const engine = new RedlineEngine(doc);
95
+ engine.process_batch([
96
+ {
97
+ type: "modify",
98
+ target_text: "Alpha beta gamma.",
99
+ new_text: "Alpha beta gamma.",
100
+ comment: "Please review.",
101
+ },
102
+ ]);
103
+ const buf = await doc.save();
104
+ const reloaded = await DocumentObject.load(buf);
105
+ const projected = await extractTextFromBuffer(buf);
106
+ const m = projected.match(/\[Com:(\d+)/);
107
+ expect(m, `expected a comment id in ${projected}`).toBeTruthy();
108
+ return [reloaded, `Com:${m![1]}`];
109
+ }
110
+
111
+ // ---------------------------------------------------------------------------
112
+ // F-04: full-span replacements across formatting runs keep words whole
113
+ // ---------------------------------------------------------------------------
114
+
115
+ describe("QA v8 F-04: cross-run full-span replacement fidelity", () => {
116
+ const TARGET =
117
+ "The Supplier shall provide the Services with reasonable skill and care.";
118
+ const NEW = "The Supplier must perform the Services professionally.";
119
+
120
+ it("leaves no partial-word bold after the replacement", async () => {
121
+ const doc = await buildCrossRunDoc();
122
+ const engine = new RedlineEngine(doc);
123
+ const stats = engine.process_batch([
124
+ { type: "modify", target_text: TARGET, new_text: NEW },
125
+ ]);
126
+ expect(stats.edits_applied).toBe(1);
127
+
128
+ const clean = cleanText(doc);
129
+ expect(clean).not.toContain("**The Suppli**");
130
+ expect(clean).not.toContain("Suppli**");
131
+ expect(clean.trim()).toBe(
132
+ "**The Supplier** must perform the Services professionally.",
133
+ );
134
+ });
135
+
136
+ it("accepted document matches the clean view", async () => {
137
+ const doc = await buildCrossRunDoc();
138
+ const engine = new RedlineEngine(doc);
139
+ engine.process_batch([
140
+ { type: "modify", target_text: TARGET, new_text: NEW },
141
+ ]);
142
+ engine.accept_all_revisions();
143
+ const accepted = _extractTextFromDoc(doc, {
144
+ cleanView: false,
145
+ includeAppendix: false,
146
+ }) as string;
147
+ expect(accepted.trim()).toBe(
148
+ "**The Supplier** must perform the Services professionally.",
149
+ );
150
+ });
151
+ });
152
+
153
+ // ---------------------------------------------------------------------------
154
+ // Hunt-profile counterexample (python property suite, 2026-07-19): a
155
+ // paragraph-splitting insertion at paragraph START must relocate the host
156
+ // paragraph's content into the LAST new paragraph. Deterministic pin,
157
+ // mirrored here because both engines shared the suffix-relocation logic.
158
+ // ---------------------------------------------------------------------------
159
+
160
+ describe("QA v8: paragraph-start splitting insertion", () => {
161
+ it("relocates the host paragraph content into the last new paragraph", async () => {
162
+ const doc = await createTestDocument();
163
+ addParagraph(doc, "00.");
164
+ const engine = new RedlineEngine(doc);
165
+ const edit: any = {
166
+ type: "modify",
167
+ target_text: "",
168
+ new_text: "0.\n\n0 ",
169
+ _match_start_index: 0,
170
+ };
171
+ engine.apply_edits([edit]);
172
+ engine.accept_all_revisions();
173
+ const final = _extractTextFromDoc(doc, {
174
+ cleanView: true,
175
+ includeAppendix: false,
176
+ }) as string;
177
+ expect(final.trim()).toBe("0.\n\n0 00.");
178
+ });
179
+ });
180
+
181
+ // ---------------------------------------------------------------------------
182
+ // F-07: blank replies and duplicate review actions are rejected
183
+ // ---------------------------------------------------------------------------
184
+
185
+ describe("QA v8 F-07: review-action validation", () => {
186
+ it("rejects a blank reply", async () => {
187
+ const [doc, comId] = await buildCommentDoc();
188
+ const engine = new RedlineEngine(doc);
189
+ expect(() =>
190
+ engine.process_batch([{ type: "reply", target_id: comId, text: " " }]),
191
+ ).toThrowError(BatchValidationError);
192
+ try {
193
+ engine.process_batch([{ type: "reply", target_id: comId, text: " " }]);
194
+ } catch (e: any) {
195
+ expect(e.errors.join("\n").toLowerCase()).toContain("empty");
196
+ }
197
+ });
198
+
199
+ it("rejects a duplicate accept of the same target_id", async () => {
200
+ const doc = await buildTrackedChangeDoc();
201
+ const engine = new RedlineEngine(doc);
202
+ try {
203
+ engine.process_batch([
204
+ { type: "accept", target_id: "Chg:1" },
205
+ { type: "accept", target_id: "Chg:1" },
206
+ ]);
207
+ expect.unreachable("duplicate accept must be rejected");
208
+ } catch (e: any) {
209
+ expect(e).toBeInstanceOf(BatchValidationError);
210
+ expect(e.errors.join("\n").toLowerCase()).toContain("duplicate");
211
+ }
212
+ });
213
+
214
+ it("rejects conflicting accept + reject on one target_id", async () => {
215
+ const doc = await buildTrackedChangeDoc();
216
+ const engine = new RedlineEngine(doc);
217
+ try {
218
+ engine.process_batch([
219
+ { type: "accept", target_id: "Chg:1" },
220
+ { type: "reject", target_id: "Chg:1" },
221
+ ]);
222
+ expect.unreachable("conflicting actions must be rejected");
223
+ } catch (e: any) {
224
+ expect(e).toBeInstanceOf(BatchValidationError);
225
+ expect(e.errors.join("\n").toLowerCase()).toContain("conflict");
226
+ }
227
+ });
228
+
229
+ it("rejects a duplicated identical reply", async () => {
230
+ const [doc, comId] = await buildCommentDoc();
231
+ const engine = new RedlineEngine(doc);
232
+ try {
233
+ engine.process_batch([
234
+ { type: "reply", target_id: comId, text: "Same reply." },
235
+ { type: "reply", target_id: comId, text: "Same reply." },
236
+ ]);
237
+ expect.unreachable("duplicate identical reply must be rejected");
238
+ } catch (e: any) {
239
+ expect(e).toBeInstanceOf(BatchValidationError);
240
+ expect(e.errors.join("\n").toLowerCase()).toContain("duplicate");
241
+ }
242
+ });
243
+
244
+ it("still allows distinct replies to the same comment", async () => {
245
+ const [doc, comId] = await buildCommentDoc();
246
+ const engine = new RedlineEngine(doc);
247
+ const stats = engine.process_batch([
248
+ { type: "reply", target_id: comId, text: "First reply." },
249
+ { type: "reply", target_id: comId, text: "Second reply." },
250
+ ]);
251
+ expect(stats.actions_applied).toBe(2);
252
+ expect(stats.actions_skipped).toBe(0);
253
+ });
254
+
255
+ it("still allows accepting the del+ins pair of one modification", async () => {
256
+ const doc = await buildTrackedChangeDoc();
257
+ const engine = new RedlineEngine(doc);
258
+ const stats = engine.process_batch([
259
+ { type: "accept", target_id: "Chg:1" },
260
+ { type: "accept", target_id: "Chg:2" },
261
+ ]);
262
+ expect(stats.actions_skipped).toBe(0);
263
+ expect(cleanText(doc)).toContain("60 days");
264
+ });
265
+ });