@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.
package/src/index.ts CHANGED
@@ -4,10 +4,11 @@ export function identifyEngine() {
4
4
 
5
5
  export { DocumentObject } from './docx/bridge.js';
6
6
  export { DocumentMapper, TextSpan } from './mapper.js';
7
- export { RedlineEngine, BatchValidationError } from './engine.js';
7
+ export { RedlineEngine, BatchValidationError, validate_edit_strings, describe_illegal_control_chars } from './engine.js';
8
8
  export { generate_edits_from_text, trim_common_context, create_unified_diff, create_word_patch_diff } from './diff.js';
9
9
  export { apply_edits_to_markdown } from './markup.js';
10
10
  export { paginate, split_structural_appendix, PaginationResult, PageInfo } from './pagination.js';
11
11
  export { extract_outline, OutlineNode } from './outline.js';
12
12
  export { extractTextFromBuffer, _extractTextFromDoc } from './ingest.js';
13
- export { finalize_document, FinalizeOptions, FinalizeResult } from './sanitize/core.js';
13
+ export { finalize_document, FinalizeOptions, FinalizeResult } from './sanitize/core.js';
14
+ export { RegexTimeoutError, userFindAllMatches, userSearch, USER_PATTERN_TIMEOUT_MS } from './utils/safe-regex.js';
package/src/mapper.ts CHANGED
@@ -2,6 +2,7 @@ import { DocumentObject } from "./docx/bridge.js";
2
2
  import { Paragraph, Table, Run, DocxEvent } from "./docx/primitives.js";
3
3
  import { findAllDescendants, findChild } from "./docx/dom.js";
4
4
  import { extract_comments_data } from "./comments.js";
5
+ import { RegexTimeoutError, userFindAllMatches, userSearch } from "./utils/safe-regex.js";
5
6
  import {
6
7
  _get_style_cache,
7
8
  get_paragraph_prefix,
@@ -105,6 +106,12 @@ export function renumber_snapshot_ids(
105
106
  return [chg_remap, com_remap];
106
107
  }
107
108
 
109
+ // Markdown style delimiters the projection emits as VIRTUAL spans around
110
+ // formatted runs (see get_run_style_markers). Literal asterisks/underscores
111
+ // typed in the document live inside real (run-backed) spans and are never
112
+ // confused with these.
113
+ const STYLE_MARKER_TEXTS = new Set(["**", "__", "*", "_"]);
114
+
108
115
  export class DocumentMapper {
109
116
  public doc: DocumentObject;
110
117
  public clean_view: boolean;
@@ -114,6 +121,7 @@ export class DocumentMapper {
114
121
  public spans: TextSpan[] = [];
115
122
  public appendix_start_index: number = -1;
116
123
  private _text_chunks: string[] = [];
124
+ private _plain_projection: [string, number[]] | null = null;
117
125
 
118
126
  constructor(
119
127
  doc: DocumentObject,
@@ -132,6 +140,7 @@ export class DocumentMapper {
132
140
  this.spans = [];
133
141
  this._text_chunks = [];
134
142
  this.full_text = "";
143
+ this._plain_projection = null;
135
144
 
136
145
  for (const part of iter_document_parts(this.doc)) {
137
146
  current_offset = this._map_blocks(part, current_offset);
@@ -800,16 +809,87 @@ export class DocumentMapper {
800
809
  return parts.join("");
801
810
  }
802
811
 
812
+ /**
813
+ * Returns [plain_text, offset_map] where plain_text is full_text with the
814
+ * VIRTUAL markdown style delimiters (bold/italic markers emitted around
815
+ * formatted runs) removed, and offset_map[i] is the full_text index of
816
+ * plain_text[i].
817
+ *
818
+ * Formatting run boundaries can fall mid-word (e.g. a paragraph projected
819
+ * as "**Al**pha"), where neither exact matching nor the whitespace-anchored
820
+ * fuzzy regex can find the plain target "Alpha". Matching against this
821
+ * projection and mapping the span back to full_text closes that gap (QA H2).
822
+ *
823
+ * Built lazily and invalidated by _build_map(): most batches never need it.
824
+ */
825
+ private _get_plain_projection(): [string, number[]] {
826
+ if (this._plain_projection === null) {
827
+ const chunks: string[] = [];
828
+ const offsets: number[] = [];
829
+ for (const s of this.spans) {
830
+ if (
831
+ s.run === null &&
832
+ s.paragraph !== null &&
833
+ STYLE_MARKER_TEXTS.has(s.text)
834
+ ) {
835
+ continue;
836
+ }
837
+ chunks.push(s.text);
838
+ for (let k = s.start; k < s.end; k++) offsets.push(k);
839
+ }
840
+ this._plain_projection = [chunks.join(""), offsets];
841
+ }
842
+ return this._plain_projection;
843
+ }
844
+
845
+ /**
846
+ * Matches a markdown-stripped target against the plain projection and maps
847
+ * each hit back to a [start, length] span in full_text. Interior style
848
+ * markers end up inside the returned span (so "Alpha" over "**Al**pha"
849
+ * resolves to the "Al**pha" range); markers just outside the matched
850
+ * characters are excluded.
851
+ */
852
+ private _find_plain_projection_matches(
853
+ target_text: string,
854
+ ): [number, number][] {
855
+ const [plain_text, offsets] = this._get_plain_projection();
856
+ if (plain_text.length === this.full_text.length) {
857
+ return []; // No virtual style markers anywhere; nothing new to find.
858
+ }
859
+ const norm_target = this._replace_smart_quotes(
860
+ this._strip_markdown_formatting(target_text),
861
+ );
862
+ if (!norm_target) return [];
863
+ const norm_plain = this._replace_smart_quotes(plain_text);
864
+ const results: [number, number][] = [];
865
+ let from = 0;
866
+ while (true) {
867
+ const p_start = norm_plain.indexOf(norm_target, from);
868
+ if (p_start === -1) break;
869
+ const p_end = p_start + norm_target.length;
870
+ const raw_start = offsets[p_start];
871
+ const raw_end = offsets[p_end - 1] + 1;
872
+ results.push([raw_start, raw_end - raw_start]);
873
+ from = p_end;
874
+ }
875
+ return results;
876
+ }
877
+
803
878
  public find_match_index(
804
879
  target_text: string,
805
880
  is_regex: boolean = false,
806
881
  ): [number, number] {
807
882
  if (is_regex) {
883
+ // User/LLM-supplied pattern: run it under a wall-clock budget so a
884
+ // catastrophic pattern cannot hang the event loop (QA 2026-07-17 F5).
885
+ // RegexTimeoutError propagates for a clean per-edit error; only
886
+ // invalid-pattern errors mean "no match" here.
808
887
  try {
809
- const pattern = new RegExp(target_text);
810
- const match = pattern.exec(this.full_text);
811
- if (match) return [match.index, match[0].length];
812
- } catch (e) {}
888
+ const match = userSearch(target_text, this.full_text);
889
+ if (match) return [match.start, match.end - match.start];
890
+ } catch (e) {
891
+ if (e instanceof RegexTimeoutError) throw e;
892
+ }
813
893
  return [-1, 0];
814
894
  }
815
895
 
@@ -827,6 +907,12 @@ export class DocumentMapper {
827
907
  return [start_idx, stripped_target.length];
828
908
  }
829
909
 
910
+ // 3.5 Plain-projection match: the target crosses a formatting run
911
+ // boundary (possibly mid-word), so the projection carries style markers
912
+ // the plain target doesn't have (QA H2).
913
+ const plain_first = this._find_plain_projection_matches(target_text);
914
+ if (plain_first.length > 0) return plain_first[0];
915
+
830
916
  try {
831
917
  const pattern = new RegExp(this._make_fuzzy_regex(target_text));
832
918
  const match = pattern.exec(this.full_text);
@@ -843,42 +929,56 @@ export class DocumentMapper {
843
929
  if (!target_text) return [];
844
930
 
845
931
  if (is_regex) {
932
+ // Budgeted like find_match_index above (QA 2026-07-17 F5).
846
933
  try {
847
- const pattern = new RegExp(target_text, "g");
848
- const matches = [...this.full_text.matchAll(pattern)];
849
- if (matches.length > 0)
850
- return matches.map((m) => [m.index!, m[0].length]);
851
- } catch (e) {}
934
+ const matches = userFindAllMatches(target_text, this.full_text);
935
+ if (matches.length > 0) return matches.map((m) => [m.start, m.end - m.start]);
936
+ } catch (e) {
937
+ if (e instanceof RegexTimeoutError) throw e;
938
+ }
852
939
  return [];
853
940
  }
854
- const escapeRegExp = (str: string) =>
855
- str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
941
+ // Exact tiers use plain indexOf scans, NOT RegExp: building a RegExp from
942
+ // an arbitrarily long escaped target throws "regular expression too
943
+ // large" for oversized inputs, crashing validation instead of returning
944
+ // a clean not-found (QA C2 hardening).
945
+ const findAllLiteral = (
946
+ haystack: string,
947
+ needle: string,
948
+ ): [number, number][] => {
949
+ const out: [number, number][] = [];
950
+ if (!needle) return out;
951
+ let from = 0;
952
+ while (true) {
953
+ const idx = haystack.indexOf(needle, from);
954
+ if (idx === -1) break;
955
+ out.push([idx, needle.length]);
956
+ from = idx + needle.length;
957
+ }
958
+ return out;
959
+ };
856
960
 
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]);
961
+ let matches = findAllLiteral(this.full_text, target_text);
962
+ if (matches.length > 0) return matches;
861
963
 
862
964
  const norm_full = this._replace_smart_quotes(this.full_text);
863
965
  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]);
966
+ matches = findAllLiteral(norm_full, norm_target);
967
+ if (matches.length > 0) return matches;
868
968
 
869
969
  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]);
970
+ matches = findAllLiteral(this.full_text, stripped_target);
971
+ if (matches.length > 0) return matches;
972
+
973
+ // 3.5 Plain-projection match (target spans a bold/italic run boundary,
974
+ // possibly mid-word). See _find_plain_projection_matches (QA H2).
975
+ const plain_matches = this._find_plain_projection_matches(target_text);
976
+ if (plain_matches.length > 0) return plain_matches;
876
977
 
877
978
  try {
878
979
  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]);
980
+ const fuzzy = [...this.full_text.matchAll(pattern)];
981
+ if (fuzzy.length > 0) return fuzzy.map((m) => [m.index!, m[0].length]);
882
982
  } catch (e) {}
883
983
 
884
984
  return [];
@@ -115,7 +115,8 @@ describe('apply_edits_to_markdown', () => {
115
115
  { type: 'modify', target_text: 'C', new_text: 'Z' }
116
116
  ];
117
117
  const result = apply_edits_to_markdown(text, edits, true);
118
- expect(result).toContain('[Edit:0]');
118
+ // 1-based, matching apply's "Edit N" reports (QA 2026-07-17 F10).
119
+ expect(result).toContain('[Edit:1]');
119
120
  expect(result.indexOf('{++X++}')).toBeLessThan(result.indexOf('{++Y++}'));
120
121
  expect(result.indexOf('{++Y++}')).toBeLessThan(result.indexOf('{++Z++}'));
121
122
  });
@@ -174,7 +175,7 @@ describe('format_ambiguity_error (Turn Loop Trap mitigation)', () => {
174
175
  expect(msg).toContain('FIRST occurrence');
175
176
 
176
177
  // The original "add more context" guidance is preserved as a third option.
177
- expect(msg).toContain('Please provide more surrounding context');
178
+ expect(msg).toContain('Provide more surrounding context');
178
179
  });
179
180
 
180
181
  it('still throws for fewer than two matches', () => {
package/src/markup.ts CHANGED
@@ -360,7 +360,9 @@ export function apply_edits_to_markdown(
360
360
  isolated_target,
361
361
  isolated_new,
362
362
  edit.comment,
363
- orig_idx,
363
+ // 1-based, matching apply's "Edit N" reports and batch validation
364
+ // errors (QA 2026-07-17 F10; mirrors Python).
365
+ orig_idx + 1,
364
366
  include_index,
365
367
  highlight_only,
366
368
  );
@@ -424,17 +426,23 @@ export function format_ambiguity_error(
424
426
 
425
427
  // Tell the agent EXACTLY how to re-call. Without this, agents loop forever
426
428
  // refining target_text/regex because they never learn that match_mode is the
427
- // built-in escape hatch for genuine ambiguity.
429
+ // built-in escape hatch for genuine ambiguity. The safe strategy (more
430
+ // context) comes first: blindly switching to "first"/"all" has silently
431
+ // modified unrelated occurrences — dates, section numbers — in real use
432
+ // (QA C1), so those options carry an explicit verification warning.
433
+ // Wording mirrors the Python engine's format_ambiguity_error.
428
434
  lines.push(" To resolve, re-send this edit using ONE of these strategies:");
429
435
  lines.push(
430
- ` 1. Set "match_mode": "all" to modify ALL ${total} occurrences (same target_text).`,
436
+ " 1. RECOMMENDED: Provide more surrounding context in your target_text to uniquely " +
437
+ 'identify a single location (keep the default "match_mode": "strict").',
431
438
  );
432
439
  lines.push(
433
- ' 2. Set "match_mode": "first" to modify only the FIRST occurrence (same target_text).',
440
+ ` 2. Set "match_mode": "all" to modify ALL ${total} occurrences — only after verifying ` +
441
+ "from the occurrence list above that EVERY occurrence should change.",
434
442
  );
435
443
  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").',
444
+ ' 3. Set "match_mode": "first" to modify only the FIRST occurrence only after verifying ' +
445
+ "the first occurrence above is the one you intend to change.",
438
446
  );
439
447
 
440
448
  return lines.join("\n");
@@ -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;