@adeu/core 1.20.0 → 1.22.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,399 @@
1
+ // FILE: src/repro_qa_report_v5.test.ts
2
+ //
3
+ // Node-side regression tests for the 2026-07-17 exploratory QA report
4
+ // (adeu 1.21.0). The report was executed against the Python CLI; per the
5
+ // "Make Both Perfect" principle each engine-level finding is verified (and
6
+ // where the defect existed, fixed) here too:
7
+ //
8
+ // F3 delete-comment no-op — Node was already safe (TS lazy getter);
9
+ // pinned here as a parity regression test
10
+ // F4 core-property scrub gaps — shared defect, fixed
11
+ // F5 ReDoS via regex:true — shared defect, fixed (vm time budget)
12
+ // F6 duplicate-definition prune — shared defect, fixed
13
+ // F8 pinned edits skip shape
14
+ // validation — Node was already safe; pinned as parity
15
+ // F9 duplicate w:id resolution — shared defect, fixed
16
+ // F10 0-based [Edit:N] — shared defect, fixed
17
+ // F11 control characters — shared defect, fixed (xmldom would have
18
+ // silently serialized an invalid DOCX)
19
+
20
+ import { describe, it, expect } from "vitest";
21
+ import { createTestDocument, addParagraph } from "./test-utils.js";
22
+ import { RedlineEngine, BatchValidationError, validate_edit_strings } from "./engine.js";
23
+ import { CommentsManager } from "./comments.js";
24
+ import { extract_all_domain_metadata } from "./domain.js";
25
+ import { apply_edits_to_markdown } from "./markup.js";
26
+ import { scrub_doc_properties, remove_all_comments } from "./sanitize/transforms.js";
27
+ import { RegexTimeoutError, userFindAllMatches } from "./utils/safe-regex.js";
28
+ import { serializeXml } from "./docx/dom.js";
29
+ import { DocumentObject } from "./docx/bridge.js";
30
+
31
+ function addTrackedInsertion(
32
+ doc: DocumentObject,
33
+ p: Element,
34
+ text: string,
35
+ wid: string,
36
+ author: string,
37
+ ): Element {
38
+ const xmlDoc = doc.element.ownerDocument!;
39
+ const ins = xmlDoc.createElement("w:ins");
40
+ ins.setAttribute("w:id", wid);
41
+ ins.setAttribute("w:author", author);
42
+ ins.setAttribute("w:date", "2026-01-02T10:00:00Z");
43
+ const r = xmlDoc.createElement("w:r");
44
+ const t = xmlDoc.createElement("w:t");
45
+ t.setAttribute("xml:space", "preserve");
46
+ t.textContent = text;
47
+ r.appendChild(t);
48
+ ins.appendChild(r);
49
+ p.appendChild(ins);
50
+ return ins;
51
+ }
52
+
53
+ // ---------------------------------------------------------------------------
54
+ // F3 — parity: deleteComment must work on a freshly constructed manager
55
+ // ---------------------------------------------------------------------------
56
+
57
+ describe("F3 parity: comment deletion on a fresh CommentsManager", () => {
58
+ it("deletes the comment from the comments part (no backing-field no-op)", async () => {
59
+ const doc = await createTestDocument();
60
+ const p = addParagraph(doc, "The purchase price shall be negotiated.");
61
+ const cm = new CommentsManager(doc);
62
+ const cid = cm.addComment("Mallory Insider", "Walk away below 9M.");
63
+
64
+ const xmlDoc = doc.element.ownerDocument!;
65
+ const rs = xmlDoc.createElement("w:commentRangeStart");
66
+ rs.setAttribute("w:id", cid);
67
+ p.insertBefore(rs, p.firstChild);
68
+ const re = xmlDoc.createElement("w:commentRangeEnd");
69
+ re.setAttribute("w:id", cid);
70
+ p.appendChild(re);
71
+
72
+ // A FRESH manager (the sanitize path) must actually delete — this is the
73
+ // exact seam where the Python engine silently no-opped (QA F3).
74
+ const fresh = new CommentsManager(doc);
75
+ fresh.deleteComment(cid);
76
+
77
+ const commentsPart = doc.pkg.parts.find((part) =>
78
+ part.partname.includes("comments"),
79
+ );
80
+ const xml = commentsPart ? serializeXml(commentsPart._element) : "";
81
+ expect(xml).not.toContain("Walk away below 9M.");
82
+ expect(xml).not.toContain("Mallory Insider");
83
+ });
84
+
85
+ it("remove_all_comments leaves no comment text or author in the package", async () => {
86
+ const doc = await createTestDocument();
87
+ const p = addParagraph(doc, "The parties agree to negotiate in good faith.");
88
+ const cm = new CommentsManager(doc);
89
+ const cid = cm.addComment("Mallory Insider", "Board approved 12M ceiling.");
90
+ const xmlDoc = doc.element.ownerDocument!;
91
+ const rs = xmlDoc.createElement("w:commentRangeStart");
92
+ rs.setAttribute("w:id", cid);
93
+ p.insertBefore(rs, p.firstChild);
94
+
95
+ const lines = remove_all_comments(doc);
96
+ expect(lines.join("\n")).toContain("Comments removed: 1");
97
+
98
+ for (const part of doc.pkg.parts) {
99
+ if (!part.partname.includes("comments")) continue;
100
+ const xml = serializeXml(part._element);
101
+ expect(xml).not.toContain("Board approved 12M ceiling.");
102
+ expect(xml).not.toContain("Mallory Insider");
103
+ }
104
+ });
105
+ });
106
+
107
+ // ---------------------------------------------------------------------------
108
+ // F4 — sanitize must scrub (or report) all leaky core properties
109
+ // ---------------------------------------------------------------------------
110
+
111
+ describe("F4: core property scrub covers title/category/keywords/subject/status", () => {
112
+ async function docWithCoreProps(): Promise<DocumentObject> {
113
+ const doc = await createTestDocument();
114
+ addParagraph(doc, "Body text.");
115
+ const corePart = doc.pkg.getPartByPath("docProps/core.xml");
116
+ expect(corePart).toBeTruthy();
117
+ const coreDoc = corePart!._element.ownerDocument!;
118
+ const root = corePart!._element;
119
+ const add = (tag: string, value: string) => {
120
+ const el = coreDoc.createElement(tag);
121
+ el.textContent = value;
122
+ root.appendChild(el);
123
+ };
124
+ add("dc:title", "Secret Merger Agreement");
125
+ add("cp:category", "Project Falcon");
126
+ add("cp:keywords", "confidential,merger,project-falcon");
127
+ add("dc:subject", "Acquisition of TargetCo");
128
+ add("cp:contentStatus", "Draft - privileged");
129
+ add("dc:description", "Internal: do not circulate");
130
+ return doc;
131
+ }
132
+
133
+ it("strips category/keywords/subject/contentStatus/description and reports them", async () => {
134
+ const doc = await docWithCoreProps();
135
+ const lines = scrub_doc_properties(doc);
136
+ const report = lines.join("\n");
137
+
138
+ const coreXml = serializeXml(doc.pkg.getPartByPath("docProps/core.xml")!._element);
139
+ expect(coreXml).not.toContain("Project Falcon");
140
+ expect(coreXml).not.toContain("project-falcon");
141
+ expect(coreXml).not.toContain("Acquisition of TargetCo");
142
+ expect(coreXml).not.toContain("Draft - privileged");
143
+ expect(coreXml).not.toContain("do not circulate");
144
+ expect(report).toContain("Category");
145
+ expect(report).toContain("Keywords");
146
+ });
147
+
148
+ it("reports the title as kept instead of silently ignoring it", async () => {
149
+ const doc = await docWithCoreProps();
150
+ const report = scrub_doc_properties(doc).join("\n");
151
+ const coreXml = serializeXml(doc.pkg.getPartByPath("docProps/core.xml")!._element);
152
+ expect(coreXml).toContain("Secret Merger Agreement"); // kept by design
153
+ expect(report).toContain("Secret Merger Agreement"); // ...but visibly
154
+ });
155
+ });
156
+
157
+ // ---------------------------------------------------------------------------
158
+ // F5 — LLM-controlled regex needs a wall-clock budget
159
+ // ---------------------------------------------------------------------------
160
+
161
+ describe("F5: user regex runs under a time budget", () => {
162
+ const REDOS_PATTERN = "(a|a)*$";
163
+ const HAYSTACK = "x" + "a".repeat(40) + "!";
164
+
165
+ it(
166
+ "userFindAllMatches interrupts catastrophic backtracking",
167
+ () => {
168
+ const t0 = Date.now();
169
+ expect(() => userFindAllMatches(REDOS_PATTERN, HAYSTACK)).toThrow(RegexTimeoutError);
170
+ expect(Date.now() - t0).toBeLessThan(10_000);
171
+ },
172
+ 15_000,
173
+ );
174
+
175
+ it(
176
+ "a ReDoS edit fails as a clean per-edit validation error",
177
+ async () => {
178
+ const doc = await createTestDocument();
179
+ addParagraph(doc, HAYSTACK);
180
+ const engine = new RedlineEngine(doc);
181
+
182
+ let errors: string[] = [];
183
+ try {
184
+ engine.process_batch([
185
+ { type: "modify", target_text: REDOS_PATTERN, new_text: "X", regex: true, match_mode: "first" } as any,
186
+ ]);
187
+ } catch (e) {
188
+ expect(e).toBeInstanceOf(BatchValidationError);
189
+ errors = (e as BatchValidationError).errors;
190
+ }
191
+ const joined = errors.join("\n");
192
+ expect(joined).toContain("Edit 1 Failed");
193
+ expect(joined.toLowerCase()).toContain("time");
194
+ },
195
+ 15_000,
196
+ );
197
+
198
+ it("a valid user regex still matches normally", async () => {
199
+ const doc = await createTestDocument();
200
+ addParagraph(doc, "The fee is 12,500 euros.");
201
+ const engine = new RedlineEngine(doc);
202
+ const res = engine.process_batch([
203
+ { type: "modify", target_text: "\\d{2},\\d{3}", new_text: "13,000", regex: true, match_mode: "first" } as any,
204
+ ]);
205
+ expect(res.edits_applied).toBe(1);
206
+ });
207
+ });
208
+
209
+ // ---------------------------------------------------------------------------
210
+ // F6 — duplicate-definition diagnostics must survive the unused-term prune
211
+ // ---------------------------------------------------------------------------
212
+
213
+ describe("F6: duplicate-definition diagnostics survive the unused prune", () => {
214
+ it("reports a duplicate that is never used", async () => {
215
+ const doc = await createTestDocument();
216
+ addParagraph(doc, '"Gadget" means a mechanical device.');
217
+ addParagraph(doc, '"Gadget" means an electronic device.');
218
+ addParagraph(doc, "Nothing else references that term at all.");
219
+ const base_text = [
220
+ '"Gadget" means a mechanical device.',
221
+ '"Gadget" means an electronic device.',
222
+ "Nothing else references that term at all.",
223
+ ].join("\n\n");
224
+
225
+ const [, diagnostics] = extract_all_domain_metadata(doc, base_text);
226
+ expect(
227
+ diagnostics.some((d) => d.includes("Duplicate Definition") && d.includes("Gadget")),
228
+ ).toBe(true);
229
+ });
230
+
231
+ it("still reports a duplicate that IS used (control)", async () => {
232
+ const doc = await createTestDocument();
233
+ addParagraph(doc, '"Widget" means a mechanical device.');
234
+ addParagraph(doc, '"Widget" means an electronic device.');
235
+ addParagraph(doc, "The Widget shall be delivered on time.");
236
+ const base_text = [
237
+ '"Widget" means a mechanical device.',
238
+ '"Widget" means an electronic device.',
239
+ "The Widget shall be delivered on time.",
240
+ ].join("\n\n");
241
+
242
+ const [, diagnostics] = extract_all_domain_metadata(doc, base_text);
243
+ expect(
244
+ diagnostics.some((d) => d.includes("Duplicate Definition") && d.includes("Widget")),
245
+ ).toBe(true);
246
+ });
247
+
248
+ it("surfaces an orphan definition as an Unused Definition warning", async () => {
249
+ const doc = await createTestDocument();
250
+ addParagraph(doc, '"Orphan Term" means something noone mentions again.');
251
+ addParagraph(doc, "The rest of the document is unrelated.");
252
+ const base_text = [
253
+ '"Orphan Term" means something noone mentions again.',
254
+ "The rest of the document is unrelated.",
255
+ ].join("\n\n");
256
+
257
+ const [, diagnostics] = extract_all_domain_metadata(doc, base_text);
258
+ expect(
259
+ diagnostics.some((d) => d.includes("Unused Definition") && d.includes("Orphan Term")),
260
+ ).toBe(true);
261
+ });
262
+ });
263
+
264
+ // ---------------------------------------------------------------------------
265
+ // F8 — parity: pinned edits pass the same string-shape validation
266
+ // ---------------------------------------------------------------------------
267
+
268
+ describe("F8 parity: pinned edits are shape-validated", () => {
269
+ it.each([
270
+ ["{++New York++}", "CriticMarkup"],
271
+ ["####### Deep heading", "Heading level 7"],
272
+ ])("rejects a pinned edit whose new_text is %s", async (bad_new_text, expected) => {
273
+ const doc = await createTestDocument();
274
+ addParagraph(doc, "This is a simple contract paragraph for testing.");
275
+ const engine = new RedlineEngine(doc);
276
+
277
+ const edit: any = {
278
+ type: "modify",
279
+ target_text: "simple",
280
+ new_text: bad_new_text,
281
+ _match_start_index: engine.mapper.full_text.indexOf("simple"),
282
+ };
283
+
284
+ expect(() => engine.process_batch([edit])).toThrow(BatchValidationError);
285
+ try {
286
+ engine.process_batch([edit]);
287
+ } catch (e) {
288
+ expect((e as BatchValidationError).errors.join("\n").toLowerCase()).toContain(
289
+ expected.toLowerCase(),
290
+ );
291
+ }
292
+ });
293
+ });
294
+
295
+ // ---------------------------------------------------------------------------
296
+ // F9 — duplicate w:id values must not let one action resolve unrelated changes
297
+ // ---------------------------------------------------------------------------
298
+
299
+ describe("F9: duplicate w:id accept/reject is refused across authors", () => {
300
+ it("refuses to reject Chg:5 shared by Alice and Bob", async () => {
301
+ const doc = await createTestDocument();
302
+ const p1 = addParagraph(doc, "Clause A fee: ");
303
+ addTrackedInsertion(doc, p1, "ALPHA", "5", "Alice");
304
+ const p2 = addParagraph(doc, "Clause B fee: ");
305
+ addTrackedInsertion(doc, p2, "BRAVO", "5", "Bob");
306
+
307
+ const engine = new RedlineEngine(doc);
308
+ let errors: string[] = [];
309
+ try {
310
+ engine.process_batch([{ type: "reject", target_id: "Chg:5" } as any]);
311
+ } catch (e) {
312
+ expect(e).toBeInstanceOf(BatchValidationError);
313
+ errors = (e as BatchValidationError).errors;
314
+ }
315
+ const joined = errors.join("\n");
316
+ expect(joined).toContain("Alice");
317
+ expect(joined).toContain("Bob");
318
+
319
+ // Neither revision may have been touched.
320
+ const xml = serializeXml(doc.element);
321
+ expect(xml).toContain("ALPHA");
322
+ expect(xml).toContain("BRAVO");
323
+ });
324
+
325
+ it("same-author id reuse (the engine's own output) stays resolvable", async () => {
326
+ const doc = await createTestDocument();
327
+ const p1 = addParagraph(doc, "Fee clause: ");
328
+ addTrackedInsertion(doc, p1, "FIRST", "7", "Adeu AI");
329
+ // Second element of the SAME logical change: same id, same author.
330
+ const p2 = addParagraph(doc, "Continued: ");
331
+ addTrackedInsertion(doc, p2, "SECOND", "7", "Adeu AI");
332
+
333
+ const engine = new RedlineEngine(doc);
334
+ const res = engine.process_batch([{ type: "accept", target_id: "Chg:7" } as any]);
335
+ expect(res.actions_applied).toBe(1);
336
+ expect(res.actions_skipped).toBe(0);
337
+ });
338
+ });
339
+
340
+ // ---------------------------------------------------------------------------
341
+ // F10 — [Edit:N] indices must be 1-based to match apply's Edit N reports
342
+ // ---------------------------------------------------------------------------
343
+
344
+ describe("F10: markup [Edit:N] indices are 1-based", () => {
345
+ it("emits [Edit:1] / [Edit:2] for a two-edit batch", () => {
346
+ const result = apply_edits_to_markdown(
347
+ "Alpha beta gamma.",
348
+ [
349
+ { type: "modify", target_text: "Alpha", new_text: "Omega" } as any,
350
+ { type: "modify", target_text: "gamma", new_text: "delta" } as any,
351
+ ],
352
+ true,
353
+ );
354
+ expect(result).toContain("[Edit:1]");
355
+ expect(result).toContain("[Edit:2]");
356
+ expect(result).not.toContain("[Edit:0]");
357
+ });
358
+ });
359
+
360
+ // ---------------------------------------------------------------------------
361
+ // F11 — control characters must fail as clean validation, not corrupt XML
362
+ // ---------------------------------------------------------------------------
363
+
364
+ describe("F11: XML-illegal control characters are rejected cleanly", () => {
365
+ it("rejects new_text containing \\x01 with a per-edit error", async () => {
366
+ const doc = await createTestDocument();
367
+ addParagraph(doc, "This is a simple contract.");
368
+ const engine = new RedlineEngine(doc);
369
+
370
+ let errors: string[] = [];
371
+ try {
372
+ engine.process_batch([
373
+ { type: "modify", target_text: "simple", new_text: "bad\x01value" } as any,
374
+ ]);
375
+ } catch (e) {
376
+ expect(e).toBeInstanceOf(BatchValidationError);
377
+ errors = (e as BatchValidationError).errors;
378
+ }
379
+ const joined = errors.join("\n");
380
+ expect(joined).toContain("Edit 1 Failed");
381
+ expect(joined.toLowerCase()).toContain("control character");
382
+ });
383
+
384
+ it("rejects a comment containing \\x00", () => {
385
+ const errors = validate_edit_strings([
386
+ { type: "modify", target_text: "a", new_text: "b", comment: "note\x00here" },
387
+ ]);
388
+ expect(errors.join("\n").toLowerCase()).toContain("control character");
389
+ });
390
+
391
+ it("without validation, xmldom would serialize the control char silently", async () => {
392
+ // Documents WHY the check must live in validation on Node: unlike lxml,
393
+ // @xmldom/xmldom writes \x01 into the XML without complaint, producing a
394
+ // package Word cannot open — worse than Python's loud traceback.
395
+ const doc = await createTestDocument();
396
+ const p = addParagraph(doc, "bad\x01char");
397
+ expect(serializeXml(p)).toContain("bad\x01char");
398
+ });
399
+ });
@@ -423,10 +423,34 @@ export function scrub_doc_properties(doc: DocumentObject): string[] {
423
423
  if (corePart) {
424
424
  const creators = findDescendantsByLocalName(corePart._element, 'creator');
425
425
  creators.forEach(c => { if (c.textContent) { lines.push(`Author: ${c.textContent}`); c.textContent = ""; }});
426
-
426
+
427
427
  const modifiers = findDescendantsByLocalName(corePart._element, 'lastModifiedBy');
428
428
  modifiers.forEach(c => { if (c.textContent) { lines.push(`Last modified by: ${c.textContent}`); c.textContent = ""; }});
429
-
429
+
430
+ // Title is often intentional, but can leak. Report it, don't strip
431
+ // (QA 2026-07-17 F4; mirrors Python).
432
+ const titles = findDescendantsByLocalName(corePart._element, 'title');
433
+ titles.forEach(c => { if (c.textContent) { lines.push(`Title kept (review manually): "${c.textContent}"`); }});
434
+
435
+ // Classification-style properties are textbook leak vectors ("Project
436
+ // Falcon", "confidential,merger,..."). Unlike title they carry no
437
+ // legitimate outbound formatting value, so strip them and say so.
438
+ const leakFields: Array<[string, string]> = [
439
+ ['category', 'Category'],
440
+ ['keywords', 'Keywords'],
441
+ ['subject', 'Subject'],
442
+ ['contentStatus', 'Content status'],
443
+ ['description', 'Description/comments'],
444
+ ];
445
+ for (const [local, label] of leakFields) {
446
+ findDescendantsByLocalName(corePart._element, local).forEach(c => {
447
+ if (c.textContent) {
448
+ lines.push(`${label}: ${c.textContent}`);
449
+ c.textContent = "";
450
+ }
451
+ });
452
+ }
453
+
430
454
  const revisions = findDescendantsByLocalName(corePart._element, 'revision');
431
455
  revisions.forEach(c => { if (c.textContent && parseInt(c.textContent) > 1) { lines.push(`Revision count: ${c.textContent} → 1`); c.textContent = "1"; }});
432
456
  }
@@ -0,0 +1,90 @@
1
+ // FILE: src/utils/safe-regex.ts
2
+ /**
3
+ * Time-budgeted execution of USER/LLM-supplied regular expressions.
4
+ *
5
+ * `regex: true` on a ModifyText edit and `search_regex` on read_docx hand an
6
+ * LLM-controlled pattern to V8's backtracking engine. A pathological pattern
7
+ * like `(a|a)*$` against a run of repeated characters hangs the event loop
8
+ * indefinitely (QA 2026-07-17 F5 — ReDoS; mirrors the Python fix).
9
+ *
10
+ * JS regexes cannot be interrupted in-thread, but V8 CAN interrupt regex
11
+ * execution running inside a `vm` context with a `timeout` — that is the
12
+ * mechanism used here. `node:vm` is a builtin, preserving the zero-dependency
13
+ * bundle constraint.
14
+ *
15
+ * Only USER-SUPPLIED patterns belong here. The engine's own generated
16
+ * patterns (fuzzy matchers etc.) are built to be linear-time.
17
+ */
18
+
19
+ import * as vm from "node:vm";
20
+
21
+ export const USER_PATTERN_TIMEOUT_MS = 2000;
22
+
23
+ export class RegexTimeoutError extends Error {
24
+ public pattern: string;
25
+ constructor(pattern: string) {
26
+ super(
27
+ `Regular expression exceeded the ${USER_PATTERN_TIMEOUT_MS / 1000}s matching time budget ` +
28
+ `(catastrophic backtracking). Simplify the pattern — nested quantifiers like (a+)+ ` +
29
+ `are the usual cause — or use a literal target instead.`,
30
+ );
31
+ this.name = "RegexTimeoutError";
32
+ this.pattern = pattern;
33
+ }
34
+ }
35
+
36
+ function runBudgeted<T>(pattern: string, script: string, sandbox: object): T {
37
+ try {
38
+ return vm.runInNewContext(script, sandbox, {
39
+ timeout: USER_PATTERN_TIMEOUT_MS,
40
+ }) as T;
41
+ } catch (e: any) {
42
+ if (e && e.code === "ERR_SCRIPT_EXECUTION_TIMEOUT") {
43
+ throw new RegexTimeoutError(pattern);
44
+ }
45
+ throw e;
46
+ }
47
+ }
48
+
49
+ /**
50
+ * All non-overlapping matches of a user pattern, under the wall-clock budget.
51
+ * Invalid patterns throw SyntaxError exactly like `new RegExp(...)` would.
52
+ * The budget covers the ENTIRE scan, not just the first match.
53
+ */
54
+ export function userFindAllMatches(
55
+ pattern: string,
56
+ text: string,
57
+ flags: string = "",
58
+ ): Array<{ start: number; end: number }> {
59
+ const normalized = flags.includes("g") ? flags : flags + "g";
60
+ const re = new RegExp(pattern, normalized); // throws SyntaxError on bad pattern
61
+ const raw = runBudgeted<Array<{ start: number; end: number }>>(
62
+ pattern,
63
+ `{
64
+ const out = [];
65
+ let m;
66
+ while ((m = re.exec(text)) !== null) {
67
+ out.push({ start: m.index, end: m.index + m[0].length });
68
+ if (m.index === re.lastIndex) re.lastIndex++;
69
+ }
70
+ out;
71
+ }`,
72
+ { re, text },
73
+ );
74
+ // Re-materialize in this realm (vm objects come from another context).
75
+ return raw.map((r) => ({ start: r.start, end: r.end }));
76
+ }
77
+
78
+ /** First match of a user pattern under the wall-clock budget, or null. */
79
+ export function userSearch(pattern: string, text: string, flags: string = ""): { start: number; end: number } | null {
80
+ const re = new RegExp(pattern, flags.replace("g", ""));
81
+ const raw = runBudgeted<{ start: number; end: number } | null>(
82
+ pattern,
83
+ `{
84
+ const m = re.exec(text);
85
+ m ? { start: m.index, end: m.index + m[0].length } : null;
86
+ }`,
87
+ { re, text },
88
+ );
89
+ return raw ? { start: raw.start, end: raw.end } : null;
90
+ }
@@ -0,0 +1,26 @@
1
+ // FILE: src/utils/text.ts
2
+ // Small text helpers shared by engine output paths. Mirrors the Python
3
+ // engine's adeu/utils/text.py so both engines bound their report output
4
+ // identically (QA C2).
5
+
6
+ // Default cap for echoing caller-supplied strings (target_text/new_text) back
7
+ // in batch reports and error messages. Reports feed straight into LLM context
8
+ // windows via MCP, so an oversized edit value must never be reflected in full
9
+ // (QA C2: a 2MB new_text was echoed twice, unbounded, in the apply report).
10
+ export const REPORT_ECHO_CAP = 500;
11
+
12
+ // Tighter cap for the inline redline preview snippets ({--...--}{++...++}),
13
+ // which additionally carry surrounding document context.
14
+ export const PREVIEW_TEXT_CAP = 200;
15
+
16
+ /**
17
+ * Bounds `text` to roughly `cap` visible characters, keeping the head and
18
+ * tail and stating how much was omitted. Returns short strings unchanged.
19
+ */
20
+ export function truncate_middle(text: string, cap: number): string {
21
+ if (text === null || text === undefined || text.length <= cap) return text;
22
+ const head = Math.max(1, Math.floor((cap * 2) / 3));
23
+ const tail = Math.max(1, cap - head);
24
+ const omitted = text.length - head - tail;
25
+ return `${text.slice(0, head)}… [${omitted.toLocaleString("en-US")} chars omitted] …${text.slice(-tail)}`;
26
+ }