@adeu/core 1.17.2 → 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.
@@ -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
+ });
@@ -57,10 +57,10 @@ describe("Webinar Report Bug Reproductions", () => {
57
57
  expect(res.edits_skipped).toBe(0);
58
58
  });
59
59
 
60
- it("Bug 2: Revision History Lockouts (Active Insertion Collisions)", async () => {
60
+ it("Bug 2: editing across a foreign author's insertion boundary is refused (provenance-safe)", async () => {
61
61
  const doc = await createTestDocument();
62
62
  const xmlDoc = doc.element.ownerDocument!;
63
-
63
+
64
64
  const p = addParagraph(doc, "The party shall provide ");
65
65
  const ins = xmlDoc.createElement("w:ins");
66
66
  ins.setAttribute("w:id", "123");
@@ -78,20 +78,22 @@ describe("Webinar Report Bug Reproductions", () => {
78
78
  suffixRun.appendChild(suffixText);
79
79
  p.appendChild(suffixRun);
80
80
 
81
- // Create an engine with a different user name to trigger co-authoring lockout
82
81
  const engine = new RedlineEngine(doc, "Reviewer");
83
82
 
84
- // Standard edit targeting the paragraph text containing the active insertion
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.)
85
89
  const edit = {
86
90
  type: "modify",
87
91
  target_text: "written notice within 30 days.",
88
92
  new_text: "notice within 15 business days.",
89
93
  };
90
-
91
- // Under normal collaborative conditions, this edit should succeed.
92
- // However, on the current unpatched codebase, the dry-run engine aborts with a BatchValidationError.
93
- const res = engine.process_batch([edit as any], true);
94
- expect(res.edits_applied).toBe(1);
94
+ expect(() => engine.process_batch([edit as any], false)).toThrow(
95
+ /active insertion from another author/,
96
+ );
95
97
  });
96
98
 
97
99
  it("Bug 3: Table Layout Ambiguity and Markdown Loss of Fidelity", async () => {
@@ -27,7 +27,7 @@ describe("QA Report V3 Defects Reproductions", () => {
27
27
  expect(res.edits_skipped).toBe(0);
28
28
  });
29
29
 
30
- it("TC2: NODE dry-run real write (active-insertion guard) [report F2, F3]", async () => {
30
+ it("TC2: NODE dry-run == real write for an edit inside a foreign insertion [report F2, F3]", async () => {
31
31
  const doc = await createTestDocument();
32
32
  const xmlDoc = doc.element.ownerDocument!;
33
33
 
@@ -37,7 +37,7 @@ describe("QA Report V3 Defects Reproductions", () => {
37
37
  ins.setAttribute("w:id", "101");
38
38
  ins.setAttribute("w:author", "Original Drafter");
39
39
  ins.setAttribute("w:date", "2026-06-29T12:00:00Z");
40
-
40
+
41
41
  const r = xmlDoc.createElement("w:r");
42
42
  const t = xmlDoc.createElement("w:t");
43
43
  t.textContent = "five (5)";
@@ -51,33 +51,25 @@ describe("QA Report V3 Defects Reproductions", () => {
51
51
  suffixRun.appendChild(suffixText);
52
52
  p.appendChild(suffixRun);
53
53
 
54
- // Create an engine with a different user name ("QA Tester") to trigger lockout
55
54
  const engine = new RedlineEngine(doc, "QA Tester");
56
55
 
57
- const edit = {
58
- type: "modify",
59
- target_text: "five (5)",
60
- new_text: "seven (7)",
61
- } as any;
62
-
63
- // Run A: dry_run = true
64
- const resDry = engine.process_batch([edit], true);
65
- expect(resDry.edits_applied).toBe(0);
66
- expect(resDry.edits_skipped).toBe(1);
67
- expect(resDry.edits[0].status).toBe("failed");
68
- expect(resDry.edits[0].error).toContain("active insertion");
69
-
70
- // Run B: dry_run = false
71
- let wetRunError: any = null;
72
- try {
73
- engine.process_batch([edit], false);
74
- } catch (e) {
75
- wetRunError = e;
76
- }
77
-
78
- expect(wetRunError).not.toBeNull();
79
- expect(wetRunError.name).toBe("BatchValidationError");
80
- expect(wetRunError.message).toContain("active insertion");
56
+ // A strict edit fully contained inside a foreign insertion now applies: the
57
+ // enclosing <w:ins> is split and the change nested. Dry-run and write agree.
58
+ // (Fresh edit object per call: a dry-run mutates the edit's resolution state.)
59
+ const resDry = engine.process_batch(
60
+ [{ type: "modify", target_text: "five (5)", new_text: "seven (7)" } as any],
61
+ true,
62
+ );
63
+ expect(resDry.edits_applied).toBe(1);
64
+ expect(resDry.edits_skipped).toBe(0);
65
+ expect(resDry.edits[0].status).toBe("applied");
66
+
67
+ const resWet = engine.process_batch(
68
+ [{ type: "modify", target_text: "five (5)", new_text: "seven (7)" } as any],
69
+ false,
70
+ );
71
+ expect(resWet.edits_applied).toBe(1);
72
+ expect(resWet.edits_skipped).toBe(0);
81
73
  });
82
74
 
83
75
  it("TC3: Heading targeted by markdown '#' corrupts instead of failing [report F4, F5]", async () => {
@@ -0,0 +1,108 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { createTestDocument, addParagraph } from "./test-utils.js";
3
+ import { RedlineEngine } from "./engine.js";
4
+ import { serializeXml } from "./docx/dom.js";
5
+
6
+ describe("Report Bug Reproductions: Boundaries and Active Insertions", () => {
7
+ it("TC_BOUNDARY: target_text spans a paragraph boundary with body text on both sides", async () => {
8
+ const doc = await createTestDocument();
9
+
10
+ // Add two paragraphs
11
+ addParagraph(doc, "First paragraph text.");
12
+ addParagraph(doc, "Second paragraph text.");
13
+
14
+ const engine = new RedlineEngine(doc);
15
+
16
+ // Target text that spans across the paragraph boundary
17
+ const edit = {
18
+ type: "modify",
19
+ target_text: "First paragraph text.\n\nSecond paragraph text.",
20
+ new_text: "First paragraph text. New.\n\nSecond paragraph text. New.",
21
+ } as any;
22
+
23
+ // On the unpatched codebase, this throws a BatchValidationError ("target_text spans a paragraph boundary").
24
+ // We assert that the batch executes successfully to replicate the failure on unpatched environments.
25
+ const result = engine.process_batch([edit], false);
26
+ expect(result.edits_applied).toBe(1);
27
+ expect(result.edits_skipped).toBe(0);
28
+ });
29
+
30
+ it("TC_CONFLICT: Modification targets an active insertion from another author", async () => {
31
+ const doc = await createTestDocument();
32
+ const xmlDoc = doc.element.ownerDocument!;
33
+
34
+ // Create a paragraph with an active insertion from another author (Supplier's Counsel)
35
+ const p = addParagraph(doc, "The party shall provide ");
36
+ const ins = xmlDoc.createElement("w:ins");
37
+ ins.setAttribute("w:id", "201");
38
+ ins.setAttribute("w:author", "Supplier's Counsel");
39
+ ins.setAttribute("w:date", "2026-06-30T08:00:00Z");
40
+
41
+ const r = xmlDoc.createElement("w:r");
42
+ const t = xmlDoc.createElement("w:t");
43
+ t.textContent = "written notice";
44
+ r.appendChild(t);
45
+ ins.appendChild(r);
46
+ p.appendChild(ins);
47
+
48
+ const suffixRun = xmlDoc.createElement("w:r");
49
+ const suffixText = xmlDoc.createElement("w:t");
50
+ suffixText.textContent = " within 30 days.";
51
+ suffixRun.appendChild(suffixText);
52
+ p.appendChild(suffixRun);
53
+
54
+ // Engine is instantiated by Reviewer AI (a different author)
55
+ const engine = new RedlineEngine(doc, "Reviewer AI");
56
+
57
+ const edit = {
58
+ type: "modify",
59
+ target_text: "written notice",
60
+ new_text: "email notification",
61
+ } as any;
62
+
63
+ // On the unpatched codebase, this throws a BatchValidationError ("Modification targets an active insertion from another author").
64
+ // We assert that the batch executes successfully to replicate the failure on unpatched environments.
65
+ const result = engine.process_batch([edit], false);
66
+ expect(result.edits_applied).toBe(1);
67
+ expect(result.edits_skipped).toBe(0);
68
+ });
69
+
70
+ it("comment-only edit on a foreign author's insertion attaches the comment without crashing", async () => {
71
+ // Regression guard (cross-engine parity): a COMMENT_ONLY edit (new_text ==
72
+ // target_text + comment) whose target lies entirely inside another author's
73
+ // pending <w:ins> must attach the comment around that insertion, not crash.
74
+ const doc = await createTestDocument();
75
+ const xmlDoc = doc.element.ownerDocument!;
76
+
77
+ const p = addParagraph(doc, "Prefix ");
78
+ const ins = xmlDoc.createElement("w:ins");
79
+ ins.setAttribute("w:id", "9");
80
+ ins.setAttribute("w:author", "Author A");
81
+ const r = xmlDoc.createElement("w:r");
82
+ const t = xmlDoc.createElement("w:t");
83
+ t.textContent = "beta";
84
+ r.appendChild(t);
85
+ ins.appendChild(r);
86
+ p.appendChild(ins);
87
+
88
+ const engine = new RedlineEngine(doc, "Editor");
89
+ const edit = {
90
+ type: "modify",
91
+ target_text: "beta",
92
+ new_text: "beta",
93
+ comment: "flag this clause",
94
+ } as any;
95
+
96
+ const result = engine.process_batch([edit], false);
97
+ expect(result.edits_applied).toBe(1);
98
+ expect(result.edits_skipped).toBe(0);
99
+
100
+ const xml = serializeXml(p);
101
+ // The foreign insertion survives and the comment range wraps it.
102
+ expect(xml).toContain('w:author="Author A"');
103
+ expect(xml.indexOf("commentRangeStart")).toBeLessThan(
104
+ xml.indexOf('w:ins w:id="9"'),
105
+ );
106
+ expect(xml).toContain("commentRangeEnd");
107
+ });
108
+ });