@adeu/core 1.19.1 → 1.21.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/mapper.ts CHANGED
@@ -105,6 +105,12 @@ export function renumber_snapshot_ids(
105
105
  return [chg_remap, com_remap];
106
106
  }
107
107
 
108
+ // Markdown style delimiters the projection emits as VIRTUAL spans around
109
+ // formatted runs (see get_run_style_markers). Literal asterisks/underscores
110
+ // typed in the document live inside real (run-backed) spans and are never
111
+ // confused with these.
112
+ const STYLE_MARKER_TEXTS = new Set(["**", "__", "*", "_"]);
113
+
108
114
  export class DocumentMapper {
109
115
  public doc: DocumentObject;
110
116
  public clean_view: boolean;
@@ -114,6 +120,7 @@ export class DocumentMapper {
114
120
  public spans: TextSpan[] = [];
115
121
  public appendix_start_index: number = -1;
116
122
  private _text_chunks: string[] = [];
123
+ private _plain_projection: [string, number[]] | null = null;
117
124
 
118
125
  constructor(
119
126
  doc: DocumentObject,
@@ -132,6 +139,7 @@ export class DocumentMapper {
132
139
  this.spans = [];
133
140
  this._text_chunks = [];
134
141
  this.full_text = "";
142
+ this._plain_projection = null;
135
143
 
136
144
  for (const part of iter_document_parts(this.doc)) {
137
145
  current_offset = this._map_blocks(part, current_offset);
@@ -800,6 +808,72 @@ export class DocumentMapper {
800
808
  return parts.join("");
801
809
  }
802
810
 
811
+ /**
812
+ * Returns [plain_text, offset_map] where plain_text is full_text with the
813
+ * VIRTUAL markdown style delimiters (bold/italic markers emitted around
814
+ * formatted runs) removed, and offset_map[i] is the full_text index of
815
+ * plain_text[i].
816
+ *
817
+ * Formatting run boundaries can fall mid-word (e.g. a paragraph projected
818
+ * as "**Al**pha"), where neither exact matching nor the whitespace-anchored
819
+ * fuzzy regex can find the plain target "Alpha". Matching against this
820
+ * projection and mapping the span back to full_text closes that gap (QA H2).
821
+ *
822
+ * Built lazily and invalidated by _build_map(): most batches never need it.
823
+ */
824
+ private _get_plain_projection(): [string, number[]] {
825
+ if (this._plain_projection === null) {
826
+ const chunks: string[] = [];
827
+ const offsets: number[] = [];
828
+ for (const s of this.spans) {
829
+ if (
830
+ s.run === null &&
831
+ s.paragraph !== null &&
832
+ STYLE_MARKER_TEXTS.has(s.text)
833
+ ) {
834
+ continue;
835
+ }
836
+ chunks.push(s.text);
837
+ for (let k = s.start; k < s.end; k++) offsets.push(k);
838
+ }
839
+ this._plain_projection = [chunks.join(""), offsets];
840
+ }
841
+ return this._plain_projection;
842
+ }
843
+
844
+ /**
845
+ * Matches a markdown-stripped target against the plain projection and maps
846
+ * each hit back to a [start, length] span in full_text. Interior style
847
+ * markers end up inside the returned span (so "Alpha" over "**Al**pha"
848
+ * resolves to the "Al**pha" range); markers just outside the matched
849
+ * characters are excluded.
850
+ */
851
+ private _find_plain_projection_matches(
852
+ target_text: string,
853
+ ): [number, number][] {
854
+ const [plain_text, offsets] = this._get_plain_projection();
855
+ if (plain_text.length === this.full_text.length) {
856
+ return []; // No virtual style markers anywhere; nothing new to find.
857
+ }
858
+ const norm_target = this._replace_smart_quotes(
859
+ this._strip_markdown_formatting(target_text),
860
+ );
861
+ if (!norm_target) return [];
862
+ const norm_plain = this._replace_smart_quotes(plain_text);
863
+ const results: [number, number][] = [];
864
+ let from = 0;
865
+ while (true) {
866
+ const p_start = norm_plain.indexOf(norm_target, from);
867
+ if (p_start === -1) break;
868
+ const p_end = p_start + norm_target.length;
869
+ const raw_start = offsets[p_start];
870
+ const raw_end = offsets[p_end - 1] + 1;
871
+ results.push([raw_start, raw_end - raw_start]);
872
+ from = p_end;
873
+ }
874
+ return results;
875
+ }
876
+
803
877
  public find_match_index(
804
878
  target_text: string,
805
879
  is_regex: boolean = false,
@@ -827,6 +901,12 @@ export class DocumentMapper {
827
901
  return [start_idx, stripped_target.length];
828
902
  }
829
903
 
904
+ // 3.5 Plain-projection match: the target crosses a formatting run
905
+ // boundary (possibly mid-word), so the projection carries style markers
906
+ // the plain target doesn't have (QA H2).
907
+ const plain_first = this._find_plain_projection_matches(target_text);
908
+ if (plain_first.length > 0) return plain_first[0];
909
+
830
910
  try {
831
911
  const pattern = new RegExp(this._make_fuzzy_regex(target_text));
832
912
  const match = pattern.exec(this.full_text);
@@ -851,34 +931,47 @@ export class DocumentMapper {
851
931
  } catch (e) {}
852
932
  return [];
853
933
  }
854
- const escapeRegExp = (str: string) =>
855
- str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
934
+ // Exact tiers use plain indexOf scans, NOT RegExp: building a RegExp from
935
+ // an arbitrarily long escaped target throws "regular expression too
936
+ // large" for oversized inputs, crashing validation instead of returning
937
+ // a clean not-found (QA C2 hardening).
938
+ const findAllLiteral = (
939
+ haystack: string,
940
+ needle: string,
941
+ ): [number, number][] => {
942
+ const out: [number, number][] = [];
943
+ if (!needle) return out;
944
+ let from = 0;
945
+ while (true) {
946
+ const idx = haystack.indexOf(needle, from);
947
+ if (idx === -1) break;
948
+ out.push([idx, needle.length]);
949
+ from = idx + needle.length;
950
+ }
951
+ return out;
952
+ };
856
953
 
857
- let matches = [
858
- ...this.full_text.matchAll(new RegExp(escapeRegExp(target_text), "g")),
859
- ];
860
- if (matches.length > 0) return matches.map((m) => [m.index!, m[0].length]);
954
+ let matches = findAllLiteral(this.full_text, target_text);
955
+ if (matches.length > 0) return matches;
861
956
 
862
957
  const norm_full = this._replace_smart_quotes(this.full_text);
863
958
  const norm_target = this._replace_smart_quotes(target_text);
864
- matches = [
865
- ...norm_full.matchAll(new RegExp(escapeRegExp(norm_target), "g")),
866
- ];
867
- if (matches.length > 0) return matches.map((m) => [m.index!, m[0].length]);
959
+ matches = findAllLiteral(norm_full, norm_target);
960
+ if (matches.length > 0) return matches;
868
961
 
869
962
  const stripped_target = this._strip_markdown_formatting(target_text);
870
- matches = [
871
- ...this.full_text.matchAll(
872
- new RegExp(escapeRegExp(stripped_target), "g"),
873
- ),
874
- ];
875
- if (matches.length > 0) return matches.map((m) => [m.index!, m[0].length]);
963
+ matches = findAllLiteral(this.full_text, stripped_target);
964
+ if (matches.length > 0) return matches;
965
+
966
+ // 3.5 Plain-projection match (target spans a bold/italic run boundary,
967
+ // possibly mid-word). See _find_plain_projection_matches (QA H2).
968
+ const plain_matches = this._find_plain_projection_matches(target_text);
969
+ if (plain_matches.length > 0) return plain_matches;
876
970
 
877
971
  try {
878
972
  const pattern = new RegExp(this._make_fuzzy_regex(target_text), "g");
879
- matches = [...this.full_text.matchAll(pattern)];
880
- if (matches.length > 0)
881
- return matches.map((m) => [m.index!, m[0].length]);
973
+ const fuzzy = [...this.full_text.matchAll(pattern)];
974
+ if (fuzzy.length > 0) return fuzzy.map((m) => [m.index!, m[0].length]);
882
975
  } catch (e) {}
883
976
 
884
977
  return [];
@@ -989,7 +1082,27 @@ export class DocumentMapper {
989
1082
  if (preceding[i].run) return [preceding[i].run, preceding[i].paragraph];
990
1083
  }
991
1084
  for (let i = preceding.length - 1; i >= 0; i--) {
992
- if (preceding[i].paragraph) return [null, preceding[i].paragraph];
1085
+ const para = preceding[i].paragraph;
1086
+ if (para) {
1087
+ // Every span ending exactly here is virtual (CriticMarkup
1088
+ // wrappers, {>>...<<} meta blocks, prefixes). If real text
1089
+ // precedes this index in the SAME paragraph, anchor after its
1090
+ // last run: falling back to the bare paragraph would drop the
1091
+ // insertion at paragraph start, ahead of the very redlines and
1092
+ // comment ranges that fence off the true position (mirrors the
1093
+ // Python mapper).
1094
+ for (let j = this.spans.length - 1; j >= 0; j--) {
1095
+ const prev = this.spans[j];
1096
+ if (
1097
+ prev.end <= index &&
1098
+ prev.run !== null &&
1099
+ prev.paragraph === para
1100
+ ) {
1101
+ return [prev.run, prev.paragraph];
1102
+ }
1103
+ }
1104
+ return [null, para];
1105
+ }
993
1106
  }
994
1107
  }
995
1108
 
@@ -174,7 +174,7 @@ describe('format_ambiguity_error (Turn Loop Trap mitigation)', () => {
174
174
  expect(msg).toContain('FIRST occurrence');
175
175
 
176
176
  // The original "add more context" guidance is preserved as a third option.
177
- expect(msg).toContain('Please provide more surrounding context');
177
+ expect(msg).toContain('Provide more surrounding context');
178
178
  });
179
179
 
180
180
  it('still throws for fewer than two matches', () => {
package/src/markup.ts CHANGED
@@ -424,17 +424,23 @@ export function format_ambiguity_error(
424
424
 
425
425
  // Tell the agent EXACTLY how to re-call. Without this, agents loop forever
426
426
  // refining target_text/regex because they never learn that match_mode is the
427
- // built-in escape hatch for genuine ambiguity.
427
+ // built-in escape hatch for genuine ambiguity. The safe strategy (more
428
+ // context) comes first: blindly switching to "first"/"all" has silently
429
+ // modified unrelated occurrences — dates, section numbers — in real use
430
+ // (QA C1), so those options carry an explicit verification warning.
431
+ // Wording mirrors the Python engine's format_ambiguity_error.
428
432
  lines.push(" To resolve, re-send this edit using ONE of these strategies:");
429
433
  lines.push(
430
- ` 1. Set "match_mode": "all" to modify ALL ${total} occurrences (same target_text).`,
434
+ " 1. RECOMMENDED: Provide more surrounding context in your target_text to uniquely " +
435
+ 'identify a single location (keep the default "match_mode": "strict").',
431
436
  );
432
437
  lines.push(
433
- ' 2. Set "match_mode": "first" to modify only the FIRST occurrence (same target_text).',
438
+ ` 2. Set "match_mode": "all" to modify ALL ${total} occurrences — only after verifying ` +
439
+ "from the occurrence list above that EVERY occurrence should change.",
434
440
  );
435
441
  lines.push(
436
- ' 3. Please provide more surrounding context in your target_text to uniquely ' +
437
- 'identify a single location (keep the default "match_mode": "strict").',
442
+ ' 3. Set "match_mode": "first" to modify only the FIRST occurrence only after verifying ' +
443
+ "the first occurrence above is the one you intend to change.",
438
444
  );
439
445
 
440
446
  return lines.join("\n");
@@ -0,0 +1,91 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { readFileSync } from "node:fs";
3
+ import { resolve, dirname } from "node:path";
4
+ import { fileURLToPath } from "node:url";
5
+ import { DocumentObject } from "./docx/bridge.js";
6
+ import { RedlineEngine } from "./engine.js";
7
+ import { extractTextFromBuffer } from "./ingest.js";
8
+ import { createTestDocument, addParagraph } from "./test-utils.js";
9
+
10
+ const __filename = fileURLToPath(import.meta.url);
11
+ const __dirname = dirname(__filename);
12
+
13
+ // Reproduction for the insertion-anchor bug (2026-07, golden.docx):
14
+ // ModifyText("document" -> "finalized document") is diff-minimized to a
15
+ // zero-width INSERTION of "finalized " immediately before "document". The
16
+ // target paragraph already carries tracked changes ({--initial --}
17
+ // {++golden ++}) and three comment ranges, so in the raw projection the
18
+ // insertion index sits right after a virtual {>>...<<} meta block.
19
+ // get_insertion_anchor only looked for a run-backed span ENDING exactly at
20
+ // that index; every span ending there is virtual, so it fell back to
21
+ // (null, paragraph) and the engine dropped the new <w:ins> at the START of
22
+ // the paragraph — "finalized This is the golden document" after accept-all.
23
+ // The fix anchors after the nearest preceding REAL run in the same
24
+ // paragraph (mirrors the Python engine/mapper fix).
25
+ describe("Zero-width insertion anchoring with preceding redlines/comments", () => {
26
+ it("lands the insertion before its target, not at paragraph start (golden.docx)", async () => {
27
+ const fixturePath = resolve(
28
+ __dirname,
29
+ "../../../../shared/fixtures/golden.docx",
30
+ );
31
+ const buf = readFileSync(fixturePath);
32
+ const doc = await DocumentObject.load(buf);
33
+ const engine = new RedlineEngine(doc, "T");
34
+
35
+ const stats = engine.process_batch([
36
+ { type: "modify", target_text: "document", new_text: "finalized document" },
37
+ ]);
38
+ expect(stats.edits_applied).toBe(1);
39
+
40
+ const out = await doc.save();
41
+ const clean = await extractTextFromBuffer(out, true);
42
+ expect(clean).toContain("golden finalized document");
43
+ expect(clean.startsWith("finalized This is")).toBe(false);
44
+
45
+ const red = await extractTextFromBuffer(out);
46
+ expect(red.startsWith("{++finalized")).toBe(false);
47
+ });
48
+
49
+ it("does not nest <w:ins> inside <w:del> when the anchor run is tracked-deleted", async () => {
50
+ const doc = await createTestDocument();
51
+ addParagraph(doc, "This is the initial document");
52
+
53
+ // Pass 1: track-delete "initial " so the raw projection becomes
54
+ // "This is the {--initial --}{>>[Chg:1 delete] T<<}document".
55
+ const engine1 = new RedlineEngine(doc, "T");
56
+ engine1.process_batch([
57
+ { type: "modify", target_text: "initial ", new_text: "" },
58
+ ]);
59
+ const deleted = await doc.save();
60
+
61
+ // Pass 2: zero-width insertion right before "document".
62
+ const doc2 = await DocumentObject.load(deleted);
63
+ const engine2 = new RedlineEngine(doc2, "T2");
64
+ engine2.process_batch([
65
+ { type: "modify", target_text: "document", new_text: "finalized document" },
66
+ ]);
67
+
68
+ const dels = Array.from(doc2.element.getElementsByTagName("w:del"));
69
+ for (const del of dels) {
70
+ expect(del.getElementsByTagName("w:ins").length).toBe(0);
71
+ }
72
+
73
+ const out = await doc2.save();
74
+ const clean = await extractTextFromBuffer(out, true);
75
+ expect(clean).toContain("This is the finalized document");
76
+ });
77
+
78
+ it("keeps genuine paragraph-start insertions at the paragraph start", async () => {
79
+ const doc = await createTestDocument();
80
+ addParagraph(doc, "Alpha beta gamma");
81
+ const engine = new RedlineEngine(doc, "T");
82
+
83
+ engine.process_batch([
84
+ { type: "modify", target_text: "Alpha", new_text: "Intro Alpha" },
85
+ ]);
86
+
87
+ const out = await doc.save();
88
+ const clean = await extractTextFromBuffer(out, true);
89
+ expect(clean).toContain("Intro Alpha beta gamma");
90
+ });
91
+ });
@@ -172,10 +172,16 @@ describe("QA Report V3 Defects Reproductions", () => {
172
172
  } as any,
173
173
  ], true);
174
174
 
175
- expect(res.edits_applied).toBe(1);
176
- expect(res.edits_skipped).toBe(1);
175
+ // Dry-run mirrors the real run's transactional semantics (QA 2026-07-17
176
+ // M1 parity): a batch with any invalid edit applies nothing. The valid
177
+ // edit is reported as blocked by the batch, the invalid one keeps its own
178
+ // error.
179
+ expect(res.edits_applied).toBe(0);
180
+ expect(res.edits_skipped).toBe(2);
181
+ expect(res.edits[0].status).toBe("failed");
182
+ expect(res.edits[0].error).toContain("transactional");
177
183
  expect(res.edits[1].status).toBe("failed");
178
-
184
+
179
185
  // Assert that the error message correctly labels it as Edit 2.
180
186
  // The buggy unpatched codebase says "Edit 1 Failed:" inside the error message for Edit 2.
181
187
  const errorMsg = res.edits[1].error;
@@ -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
+ }