@adeu/core 1.13.0 → 1.15.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/domain.ts CHANGED
@@ -1,7 +1,8 @@
1
- import { DocumentObject } from './docx/bridge.js';
2
- import { Paragraph, Run } from './docx/primitives.js';
3
- import { iter_block_items, get_run_text } from './utils/docx.js';
4
- import { findAllDescendants } from './docx/dom.js';
1
+ import { DocumentObject } from "./docx/bridge.js";
2
+ import { Paragraph, Run } from "./docx/primitives.js";
3
+ import { iter_block_items, get_run_text } from "./utils/docx.js";
4
+ import { findAllDescendants } from "./docx/dom.js";
5
+ import { findDescendantsByLocalName } from "./sanitize/transforms.js";
5
6
 
6
7
  function boundedLevenshtein(a: string, b: string, maxDist: number = 2): number {
7
8
  if (a === b) return 0;
@@ -22,11 +23,7 @@ function boundedLevenshtein(a: string, b: string, maxDist: number = 2): number {
22
23
  let minInRow = i;
23
24
  for (let j = 1; j <= a.length; j++) {
24
25
  const cost = a[j - 1] === b[i - 1] ? 0 : 1;
25
- const val = Math.min(
26
- row[j] + 1,
27
- newRow[j - 1] + 1,
28
- row[j - 1] + cost
29
- );
26
+ const val = Math.min(row[j] + 1, newRow[j - 1] + 1, row[j - 1] + cost);
30
27
  newRow.push(val);
31
28
  if (val < minInRow) minInRow = val;
32
29
  }
@@ -37,8 +34,8 @@ function boundedLevenshtein(a: string, b: string, maxDist: number = 2): number {
37
34
  }
38
35
 
39
36
  function _get_paragraph_text(p: Paragraph): string {
40
- let text = '';
41
- const runs = findAllDescendants(p._element, 'w:r');
37
+ let text = "";
38
+ const runs = findAllDescendants(p._element, "w:r");
42
39
  for (const r of runs) {
43
40
  text += get_run_text(new Run(r, p));
44
41
  }
@@ -47,14 +44,22 @@ function _get_paragraph_text(p: Paragraph): string {
47
44
 
48
45
  export function extract_all_domain_metadata(
49
46
  doc: DocumentObject,
50
- base_text: string
51
- ): [Record<string, { count: number }>, string[], Record<string, { anchored_to: string; referenced_from: string[] }>] {
47
+ base_text: string,
48
+ ): [
49
+ Record<string, { count: number }>,
50
+ string[],
51
+ Record<string, { anchored_to: string; referenced_from: string[] }>,
52
+ ] {
52
53
  const definitions: Record<string, { count: number }> = {};
53
54
  const duplicates = new Set<string>();
54
- const raw_anchors: Record<string, { anchored_to: string; referenced_from: string[] }> = {};
55
+ const raw_anchors: Record<
56
+ string,
57
+ { anchored_to: string; referenced_from: string[] }
58
+ > = {};
55
59
  const raw_references: [string, string][] = [];
56
60
 
57
- const leading_re = /^(?:[\d.\-()a-zA-Z]+\s*)?["“]([A-Z][A-Za-z0-9\s\-&'’]{1,60})["”]/;
61
+ const leading_re =
62
+ /^(?:[\d.\-()a-zA-Z]+\s*)?["“]([A-Z][A-Za-z0-9\s\-&'’]{1,60})["”]/;
58
63
  const inline_re = /\([^)]*?["“]([A-Z][A-Za-z0-9\s\-&'’]{1,60})["”][^)]*?\)/g;
59
64
 
60
65
  for (const item of iter_block_items(doc)) {
@@ -77,28 +82,31 @@ export function extract_all_domain_metadata(
77
82
  else definitions[term] = { count: 0 };
78
83
  }
79
84
 
80
- const short_text = text.length > 60 ? text.substring(0, 60) + '...' : text;
85
+ const short_text = text.length > 60 ? text.substring(0, 60) + "..." : text;
81
86
 
82
- const nodes = findAllDescendants(item._element, '*');
87
+ const nodes = findAllDescendants(item._element, "*");
83
88
  for (const node of nodes) {
84
- if (node.tagName === 'w:bookmarkStart') {
85
- const b_name = node.getAttribute('w:name');
86
- if (b_name && (!b_name.startsWith('_') || b_name.startsWith('_Ref'))) {
89
+ if (node.tagName === "w:bookmarkStart") {
90
+ const b_name = node.getAttribute("w:name");
91
+ if (b_name && (!b_name.startsWith("_") || b_name.startsWith("_Ref"))) {
87
92
  if (!raw_anchors[b_name]) {
88
- raw_anchors[b_name] = { anchored_to: short_text, referenced_from: [] };
93
+ raw_anchors[b_name] = {
94
+ anchored_to: short_text,
95
+ referenced_from: [],
96
+ };
89
97
  }
90
98
  }
91
99
  }
92
100
 
93
101
  let target: string | null = null;
94
- if (node.tagName === 'w:fldSimple') {
95
- const instr = node.getAttribute('w:instr') || '';
102
+ if (node.tagName === "w:fldSimple") {
103
+ const instr = node.getAttribute("w:instr") || "";
96
104
  const parts = instr.trim().split(/\s+/);
97
- if (parts.length > 1 && parts[0] === 'REF') target = parts[1];
98
- } else if (node.tagName === 'w:instrText') {
99
- const instr = node.textContent || '';
105
+ if (parts.length > 1 && parts[0] === "REF") target = parts[1];
106
+ } else if (node.tagName === "w:instrText") {
107
+ const instr = node.textContent || "";
100
108
  const parts = instr.trim().split(/\s+/);
101
- if (parts.length > 1 && parts[0] === 'REF') target = parts[1];
109
+ if (parts.length > 1 && parts[0] === "REF") target = parts[1];
102
110
  }
103
111
 
104
112
  if (target) raw_references.push([target, short_text]);
@@ -116,9 +124,10 @@ export function extract_all_domain_metadata(
116
124
  const def_keys = Object.keys(definitions);
117
125
  if (def_keys.length > 0) {
118
126
  const sorted_terms = def_keys.sort((a, b) => b.length - a.length);
119
- const escapeRegExp = (str: string) => str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
120
- const alt = sorted_terms.map(escapeRegExp).join('|');
121
- const usage_pattern = new RegExp(`(?<!["“])\\b(${alt})\\b(?![”"])`, 'g');
127
+ const escapeRegExp = (str: string) =>
128
+ str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
129
+ const alt = sorted_terms.map(escapeRegExp).join("|");
130
+ const usage_pattern = new RegExp(`(?<!["“])\\b(${alt})\\b(?![”"])`, "g");
122
131
 
123
132
  for (const m of base_text.matchAll(usage_pattern)) {
124
133
  const matched_term = m[1];
@@ -134,12 +143,32 @@ export function extract_all_domain_metadata(
134
143
  }
135
144
 
136
145
  for (const term of duplicates) {
137
- diagnostics.push(`[Error] Duplicate Definition: '${term}' is defined multiple times.`);
146
+ diagnostics.push(
147
+ `[Error] Duplicate Definition: '${term}' is defined multiple times.`,
148
+ );
138
149
  }
139
150
 
140
151
  const stop_words = new Set([
141
- "The", "This", "That", "Such", "A", "An", "Any", "All", "Some", "No",
142
- "Every", "Each", "As", "In", "Of", "For", "To", "On", "By", "With"
152
+ "The",
153
+ "This",
154
+ "That",
155
+ "Such",
156
+ "A",
157
+ "An",
158
+ "Any",
159
+ "All",
160
+ "Some",
161
+ "No",
162
+ "Every",
163
+ "Each",
164
+ "As",
165
+ "In",
166
+ "Of",
167
+ "For",
168
+ "To",
169
+ "On",
170
+ "By",
171
+ "With",
143
172
  ]);
144
173
 
145
174
  const all_cap_pattern = /\b[A-Z][a-zA-Z]*(?:\s+[A-Z][a-zA-Z]*)*\b/g;
@@ -160,11 +189,12 @@ export function extract_all_domain_metadata(
160
189
  const words = candidate.split(/\s+/);
161
190
  while (words.length > 0) {
162
191
  const first = words[0];
163
- const title = first.charAt(0).toUpperCase() + first.slice(1).toLowerCase();
192
+ const title =
193
+ first.charAt(0).toUpperCase() + first.slice(1).toLowerCase();
164
194
  if (stop_words.has(title)) words.shift();
165
195
  else break;
166
196
  }
167
- candidate = words.join(' ');
197
+ candidate = words.join(" ");
168
198
 
169
199
  if (candidate.length < 4) continue;
170
200
  if (valid_terms.has(candidate)) continue;
@@ -180,8 +210,8 @@ export function extract_all_domain_metadata(
180
210
 
181
211
  for (const term of candidate_terms) {
182
212
  if (Math.abs(candidate.length - term.length) > 2) continue;
183
- if (candidate === term + 's' || candidate === term + 'es') continue;
184
- if (term === candidate + 's' || term === candidate + 'es') continue;
213
+ if (candidate === term + "s" || candidate === term + "es") continue;
214
+ if (term === candidate + "s" || term === candidate + "es") continue;
185
215
 
186
216
  const dist = boundedLevenshtein(candidate, term, 2);
187
217
  if (dist === 0 || dist > 2) continue;
@@ -192,19 +222,20 @@ export function extract_all_domain_metadata(
192
222
  }
193
223
 
194
224
  if (!candidates_by_term[term]) candidates_by_term[term] = [];
195
- if (!candidates_by_term[term].includes(candidate)) candidates_by_term[term].push(candidate);
225
+ if (!candidates_by_term[term].includes(candidate))
226
+ candidates_by_term[term].push(candidate);
196
227
  }
197
228
  }
198
229
 
199
230
  for (const [term, candidates] of Object.entries(candidates_by_term)) {
200
231
  candidates.sort();
201
- const c_str = candidates.map(c => `'${c}'`).join(', ');
232
+ const c_str = candidates.map((c) => `'${c}'`).join(", ");
202
233
  diagnostics.push(`[Info] Possible Typos for '${term}': Found ${c_str}`);
203
234
  }
204
235
 
205
236
  function diag_sort_key(msg: string) {
206
- if (msg.startsWith('[Error]')) return 0;
207
- if (msg.startsWith('[Warning]')) return 1;
237
+ if (msg.startsWith("[Error]")) return 0;
238
+ if (msg.startsWith("[Warning]")) return 1;
208
239
  return 2;
209
240
  }
210
241
 
@@ -218,19 +249,88 @@ export function extract_all_domain_metadata(
218
249
  return [definitions, diagnostics, raw_anchors];
219
250
  }
220
251
 
221
- export function build_structural_appendix(doc: DocumentObject, base_text: string): string {
222
- const [defs, diagnostics, anchors] = extract_all_domain_metadata(doc, base_text);
252
+ /**
253
+ * Inspects word/settings.xml for privacy flags that cause Microsoft Word to
254
+ * silently strip authorship metadata (w:author, w:date, w:initials) from
255
+ * tracked changes and comments on the next save. These flags do not affect
256
+ * what this engine writes — but they will scrub the engine's attribution the
257
+ * next time Word opens and saves the file, breaking auditability and
258
+ * multi-turn agent state tracking.
259
+ *
260
+ * Returns a list of human-readable warning lines (without leading bullets);
261
+ * empty when no relevant flag is present or all flags are explicitly disabled.
262
+ */
263
+ export function extract_document_settings_warnings(
264
+ doc: DocumentObject,
265
+ ): string[] {
266
+ const warnings: string[] = [];
267
+ const settingsPart = doc.pkg.getPartByPath("word/settings.xml");
268
+ if (!settingsPart) return warnings;
269
+
270
+ const isTruthy = (el: Element): boolean => {
271
+ // OOXML boolean rule: element present with no w:val attribute defaults to true.
272
+ // w:val of "0", "false", or "off" means disabled. Anything else (including
273
+ // "1", "true", "on") is enabled.
274
+ if (!el.hasAttribute("w:val")) return true;
275
+ const val = (el.getAttribute("w:val") || "").toLowerCase();
276
+ return val !== "0" && val !== "false" && val !== "off";
277
+ };
278
+
279
+ const removePersonal = findDescendantsByLocalName(
280
+ settingsPart._element,
281
+ "removePersonalInformation",
282
+ );
283
+ if (removePersonal.length > 0 && isTruthy(removePersonal[0])) {
284
+ warnings.push(
285
+ "[Warning] Privacy flag `removePersonalInformation` is enabled in word/settings.xml. " +
286
+ "Microsoft Word will strip the `w:author`, `w:initials`, and `w:date` attributes from every tracked change and comment the next time this document is opened and saved. " +
287
+ "Edits made by this agent will lose attribution, breaking audit trails and any multi-turn workflow that relies on identifying prior edits.",
288
+ );
289
+ }
290
+
291
+ const removeDateTime = findDescendantsByLocalName(
292
+ settingsPart._element,
293
+ "removeDateAndTime",
294
+ );
295
+ if (removeDateTime.length > 0 && isTruthy(removeDateTime[0])) {
296
+ warnings.push(
297
+ "[Warning] Privacy flag `removeDateAndTime` is enabled in word/settings.xml. " +
298
+ "Microsoft Word will strip the `w:date` attribute from every tracked change and comment the next time this document is opened and saved. " +
299
+ "Timestamps on this agent's edits will be lost on the next Word save.",
300
+ );
301
+ }
302
+
303
+ return warnings;
304
+ }
305
+
306
+ export function build_structural_appendix(
307
+ doc: DocumentObject,
308
+ base_text: string,
309
+ ): string {
310
+ const [defs, diagnostics, anchors] = extract_all_domain_metadata(
311
+ doc,
312
+ base_text,
313
+ );
314
+ const settings_warnings = extract_document_settings_warnings(doc);
223
315
 
224
316
  const lines: string[] = [
225
317
  "\n\n---",
226
318
  "",
227
319
  "<!-- READONLY_BOUNDARY_START -->",
228
320
  "# Document Structure (Read-Only)",
229
- "The content below is metadata describing the document's reference structure. Do not include this section in any tracked changes or edits \u2014 it is for your context only and will be discarded on write."
321
+ "The content below is metadata describing the document's reference structure. Do not include this section in any tracked changes or edits \u2014 it is for your context only and will be discarded on write.",
230
322
  ];
231
323
 
232
324
  let has_content = false;
233
325
 
326
+ if (settings_warnings.length > 0) {
327
+ has_content = true;
328
+ lines.push("\n## Document Settings");
329
+ for (const warning of settings_warnings) {
330
+ lines.push(`- ${warning}`);
331
+ }
332
+ }
333
+
234
334
  if (Object.keys(defs).length > 0) {
235
335
  has_content = true;
236
336
  lines.push("\n## Defined Terms");
@@ -259,7 +359,7 @@ export function build_structural_appendix(doc: DocumentObject, base_text: string
259
359
  }
260
360
 
261
361
  if (has_content) {
262
- return lines.join('\n');
362
+ return lines.join("\n");
263
363
  }
264
364
  return "";
265
- }
365
+ }
@@ -531,4 +531,24 @@ describe("Resolved Bugs Core Engine Verification", () => {
531
531
  const final_comment_parts = doc.pkg.parts.filter(p => p.contentType.includes("comments"));
532
532
  expect(final_comment_parts.length).toBe(0);
533
533
  });
534
+
535
+ it("Double-Serialization Core: process_batch successfully processes double-serialized JSON strings", async () => {
536
+ const doc = await createTestDocument();
537
+ addParagraph(doc, "original text");
538
+ const engine = new RedlineEngine(doc);
539
+
540
+ // On unpatched code, this will throw a raw TypeError: Cannot create property '_applied_status' on string
541
+ // On patched code, it will successfully parse and apply the modify change
542
+ engine.process_batch([
543
+ JSON.stringify({
544
+ type: "modify",
545
+ target_text: "original text",
546
+ new_text: "updated text",
547
+ }),
548
+ ] as any);
549
+
550
+ const buf = await doc.save();
551
+ const text = await extractTextFromBuffer(buf, true);
552
+ expect(text).toContain("updated text");
553
+ });
534
554
  });
@@ -0,0 +1,162 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { RedlineEngine } from "./engine.js";
3
+ import { createTestDocument, addParagraph } from "./test-utils.js";
4
+
5
+ describe("QA Report V2: Engine Logic", () => {
6
+ it("F6: Breadcrumb resolver does not dump paragraph bodies", async () => {
7
+ const doc = await createTestDocument();
8
+
9
+ // Setup a heading
10
+ const p = addParagraph(doc, "2. Confidentiality");
11
+ const docEl = p.ownerDocument!;
12
+ const pPr = docEl.createElement("w:pPr");
13
+ const pStyle = docEl.createElement("w:pStyle");
14
+ pStyle.setAttribute("w:val", "Heading1");
15
+ pPr.appendChild(pStyle);
16
+ p.insertBefore(pPr, p.firstChild);
17
+
18
+ // Add a massive paragraph that could erroneously get caught by a greedy regex
19
+ addParagraph(doc, "This is a massive body paragraph ".repeat(20));
20
+ addParagraph(doc, "Target phrase is here.");
21
+
22
+ const engine = new RedlineEngine(doc);
23
+ engine.mapper["_build_map"]();
24
+
25
+ const text = engine.mapper.full_text;
26
+ const start_idx = text.indexOf("Target phrase");
27
+
28
+ const [path, page] = (engine as any)._get_heading_path_and_page(start_idx, text, [0, 1000]);
29
+
30
+ expect(path).toContain("2. Confidentiality");
31
+ expect(path).not.toContain("This is a massive body paragraph");
32
+ });
33
+
34
+ it("F9: Empty insertion CriticMarkup artifact {++++} is suppressed", async () => {
35
+ const doc = await createTestDocument();
36
+ addParagraph(doc, "the Board of Directors");
37
+ const engine = new RedlineEngine(doc);
38
+
39
+ // Mock an edit that acts as a strict deletion/shrink (new_text is empty)
40
+ const edit = {
41
+ type: "modify",
42
+ target_text: " of Directors",
43
+ new_text: "",
44
+ _resolved_start_idx: "the Board".length,
45
+ _active_mapper_ref: engine.mapper
46
+ };
47
+
48
+ engine.mapper["_build_map"]();
49
+
50
+ const [critic_markup, clean_text] = (engine as any)._build_edit_context_previews(edit);
51
+
52
+ expect(critic_markup).toContain("{-- of Directors--}");
53
+ // The {++++} artifact shouldn't exist if new_text is empty
54
+ expect(critic_markup).not.toContain("{++++}");
55
+ expect(clean_text).not.toContain("{++++}");
56
+ });
57
+
58
+ it("R2 & R3: all-mode page list is ascending and breadcrumb anchors on first occurrence", async () => {
59
+ const doc = await createTestDocument();
60
+ const xmlDoc = doc.element.ownerDocument!;
61
+
62
+ const p1 = addParagraph(doc, "Heading one");
63
+ const pPr1 = xmlDoc.createElement("w:pPr");
64
+ const pStyle1 = xmlDoc.createElement("w:pStyle");
65
+ pStyle1.setAttribute("w:val", "Heading1");
66
+ pPr1.appendChild(pStyle1);
67
+ p1.insertBefore(pPr1, p1.firstChild);
68
+
69
+ addParagraph(doc, "Occurrence one.");
70
+
71
+ const p3 = addParagraph(doc, "Heading two");
72
+ const pPr2 = xmlDoc.createElement("w:pPr");
73
+ const pStyle2 = xmlDoc.createElement("w:pStyle");
74
+ pStyle2.setAttribute("w:val", "Heading2");
75
+ pPr2.appendChild(pStyle2);
76
+ p3.insertBefore(pPr2, p3.firstChild);
77
+
78
+ addParagraph(doc, "Occurrence two.");
79
+
80
+ const engine = new RedlineEngine(doc);
81
+ const edit = {
82
+ type: "modify",
83
+ target_text: "Occurrence",
84
+ new_text: "Match",
85
+ match_mode: "all"
86
+ };
87
+ const stats = engine.process_batch([edit], false);
88
+
89
+ const report = stats.edits[0];
90
+ expect(report.heading_path).toContain("Heading one");
91
+ expect(report.heading_path).not.toContain("Heading two");
92
+
93
+ if (report.pages && report.pages.length > 1) {
94
+ expect(report.pages[0]).toBeLessThanOrEqual(report.pages[1]);
95
+ }
96
+ });
97
+
98
+ it("§5.3.2 Transactional rollback for foreign author", async () => {
99
+ const doc = await createTestDocument();
100
+ const xmlDoc = doc.element.ownerDocument!;
101
+
102
+ const p = addParagraph(doc, "Target ");
103
+ const ins = xmlDoc.createElement("w:ins");
104
+ ins.setAttribute("w:id", "1");
105
+ ins.setAttribute("w:author", "Other User");
106
+ const r = xmlDoc.createElement("w:r");
107
+ const t = xmlDoc.createElement("w:t");
108
+ t.textContent = "word";
109
+ r.appendChild(t);
110
+ ins.appendChild(r);
111
+ p.appendChild(ins);
112
+
113
+ const engine = new RedlineEngine(doc);
114
+ const edit = {
115
+ type: "modify",
116
+ target_text: "Target word",
117
+ new_text: "Replaced",
118
+ match_mode: "all"
119
+ };
120
+
121
+ expect(() => engine.process_batch([edit], false)).toThrow(/targets an active insertion from another author/);
122
+ });
123
+
124
+ it("§5.3.3 Double-sided paragraph merge regex rejection", async () => {
125
+ const doc = await createTestDocument();
126
+ addParagraph(doc, "First part.");
127
+ addParagraph(doc, "Second part.");
128
+
129
+ const engine = new RedlineEngine(doc);
130
+ const edit = {
131
+ type: "modify",
132
+ target_text: "part.\\n\\nSecond",
133
+ new_text: "merged",
134
+ regex: true
135
+ };
136
+
137
+ expect(() => engine.process_batch([edit], false)).toThrow(/spans a paragraph boundary with body text on both sides/);
138
+ });
139
+
140
+ it("S1: Transactional rollback blocks all-mode edit overlapping foreign COMMENT_ONLY edit", async () => {
141
+ const doc = await createTestDocument();
142
+ addParagraph(doc, "This is constituting the Board of Directors today.");
143
+
144
+ const engineAlice = new RedlineEngine(doc, "Alice");
145
+ engineAlice.process_batch([{
146
+ type: "modify",
147
+ target_text: "constituting the Board of Directors",
148
+ new_text: "constituting the Board of Directors",
149
+ comment: "Alice touches this clause"
150
+ }]);
151
+
152
+ const engineBob = new RedlineEngine(doc, "Bob");
153
+ const editBob = {
154
+ type: "modify",
155
+ target_text: "the Board of Directors",
156
+ new_text: "the Supervisory Board",
157
+ match_mode: "all"
158
+ };
159
+
160
+ expect(() => engineBob.process_batch([editBob], false)).toThrow(/another author/i);
161
+ });
162
+ });