@adeu/core 1.21.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@adeu/core",
3
- "version": "1.21.0",
3
+ "version": "1.22.0",
4
4
  "description": "",
5
5
  "main": "./dist/index.js",
6
6
  "types": "./dist/index.d.ts",
package/src/domain.ts CHANGED
@@ -134,10 +134,19 @@ export function extract_all_domain_metadata(
134
134
  if (definitions[matched_term]) definitions[matched_term].count++;
135
135
  }
136
136
 
137
+ // Drop unused terms from the SYMBOL TABLE only — the filter is noise
138
+ // reduction for the Defined Terms listing, and must not gate the
139
+ // Semantic Diagnostics: a term defined twice and never used is two
140
+ // drafting errors, not zero (QA 2026-07-17 F6; mirrors Python). Surface
141
+ // the orphan definition itself as a diagnostic instead.
137
142
  for (const term of def_keys) {
138
143
  if (definitions[term].count === 0) {
139
144
  delete definitions[term];
140
- duplicates.delete(term);
145
+ if (!duplicates.has(term)) {
146
+ diagnostics.push(
147
+ `[Warning] Unused Definition: '${term}' is defined but never used.`,
148
+ );
149
+ }
141
150
  }
142
151
  }
143
152
  }
package/src/engine.ts CHANGED
@@ -27,6 +27,7 @@ import {
27
27
  REPORT_ECHO_CAP,
28
28
  truncate_middle,
29
29
  } from "./utils/text.js";
30
+ import { RegexTimeoutError } from "./utils/safe-regex.js";
30
31
 
31
32
  // Width of the surrounding-document window shown in redline previews.
32
33
  const PREVIEW_CONTEXT_CHARS = 30;
@@ -178,6 +179,20 @@ const TRANSACTIONAL_NOT_APPLIED_ERROR =
178
179
  "Not applied: the batch is transactional and other edits failed " +
179
180
  "validation (see their errors). Fix or remove those edits and re-run.";
180
181
 
182
+ // Characters XML 1.0 cannot represent: C0 controls except tab/newline/CR.
183
+ // Word refuses to open a package carrying them, and @xmldom serializes them
184
+ // silently, so they must be rejected before they reach the DOM
185
+ // (QA 2026-07-17 F11; mirrors Python's clean per-edit error).
186
+ const XML_ILLEGAL_CHARS_RE = /[\x00-\x08\x0b\x0c\x0e-\x1f]/g;
187
+
188
+ export function describe_illegal_control_chars(text: string): string | null {
189
+ if (!text) return null;
190
+ const found = text.match(XML_ILLEGAL_CHARS_RE);
191
+ if (!found) return null;
192
+ const codes = Array.from(new Set(found.map((c) => `0x${c.charCodeAt(0).toString(16).padStart(2, "0")}`))).sort();
193
+ return codes.join(", ");
194
+ }
195
+
181
196
  export function validate_edit_strings(
182
197
  edits: any[],
183
198
  index_offset: number = 0,
@@ -189,6 +204,25 @@ export function validate_edit_strings(
189
204
  const t_text = edit.target_text || "";
190
205
  const n_text = edit.new_text || "";
191
206
 
207
+ // VAL-CRIT-8: XML-illegal control characters (QA 2026-07-17 F11).
208
+ const checked_fields: Array<[string, string]> = [
209
+ ["target_text", t_text],
210
+ ["new_text", n_text],
211
+ ];
212
+ if (edit.comment) checked_fields.push(["comment", edit.comment]);
213
+ (edit.cells || []).forEach((cell: string, cell_idx: number) => {
214
+ checked_fields.push([`cells[${cell_idx}]`, cell || ""]);
215
+ });
216
+ for (const [field_name, field_value] of checked_fields) {
217
+ const described = describe_illegal_control_chars(field_value);
218
+ if (described) {
219
+ errors.push(
220
+ `- Edit ${i + 1 + index_offset} Failed: \`${field_name}\` contains control character(s) ` +
221
+ `(${described}) that cannot be stored in a DOCX. Remove them and re-submit.`,
222
+ );
223
+ }
224
+ }
225
+
192
226
  if (
193
227
  n_text.includes("{++") ||
194
228
  n_text.includes("{--") ||
@@ -571,8 +605,8 @@ export class RedlineEngine {
571
605
  * Snapshots the document text around a resolved edit BEFORE anything is
572
606
  * applied. Previews rendered after the batch mutates the DOM cannot slice
573
607
  * full_text at the stored offsets: applied edits shift offsets and inject
574
- * tracked-change markup, which produced garbled previews mixing unrelated
575
- * edits and internal scaffolding (QA H1).
608
+ * tracked-change markup, garbling previews with unrelated edits and
609
+ * internal scaffolding (QA H1).
576
610
  */
577
611
  private _capture_preview_context(edit: any): void {
578
612
  if (edit.type !== "modify") return;
@@ -690,8 +724,8 @@ export class RedlineEngine {
690
724
 
691
725
  /**
692
726
  * The "new text" a batch report should show for an edit. InsertTableRow has
693
- * no new_text field — surface its cell contents instead of the misleading
694
- * empty string the report used to print (QA M4).
727
+ * no new_text field — surface its cell contents rather than a misleading
728
+ * empty string (QA M4).
695
729
  */
696
730
  private static _report_new_text(edit: any): string {
697
731
  if (edit && edit.type === "insert_row" && Array.isArray(edit.cells)) {
@@ -2114,7 +2148,7 @@ export class RedlineEngine {
2114
2148
 
2115
2149
  // Structural table edits: verify the anchor really is a table row, and
2116
2150
  // that insert_row does not provide more cells than the row has columns —
2117
- // extra cells used to produce a structurally inconsistent row (QA M3).
2151
+ // extra cells must never produce a structurally inconsistent row (QA M3).
2118
2152
  if (
2119
2153
  (edit.type === "insert_row" || edit.type === "delete_row") &&
2120
2154
  matches.length > 0
@@ -2297,16 +2331,14 @@ export class RedlineEngine {
2297
2331
  !["accept", "reject", "reply"].includes(c.type),
2298
2332
  );
2299
2333
 
2300
- // NOTE: a previous "edits_for_merge" pre-pass here silently UNWRAPPED a
2301
- // foreign author's <w:ins> when a strict/first edit only partially straddled
2302
- // its boundary turning that author's tracked-inserted text into untracked
2303
- // committed body text before the edit applied, destroying their provenance.
2304
- // That is the same provenance-laundering failure mode the canonical engine
2305
- // refuses, so it has been removed: a partial straddle now surfaces the
2306
- // standard validation error ("Modification targets an active insertion from
2307
- // another author …") via validate_edits, matching the Python engine. An edit
2308
- // fully CONTAINED inside a foreign <w:ins> stays allowed and is handled by
2309
- // nesting the <w:del> inside that <w:ins> (see _apply_single_edit_indexed /
2334
+ // Never pre-unwrap a foreign author's <w:ins> to make a partially
2335
+ // straddling edit fit: that turns their tracked-inserted text into
2336
+ // untracked committed body text before the edit applies, destroying
2337
+ // their provenance. A partial straddle surfaces the standard validation
2338
+ // error ("Modification targets an active insertion from another author
2339
+ // …") via validate_edits, matching the Python engine. An edit fully
2340
+ // CONTAINED inside a foreign <w:ins> is allowed and handled by nesting
2341
+ // the <w:del> inside that <w:ins> (see _apply_single_edit_indexed /
2310
2342
  // _insert_and_split_ins).
2311
2343
 
2312
2344
  // BUG-7: Unified single-pass validation in wet-run / standard mode.
@@ -2391,7 +2423,15 @@ export class RedlineEngine {
2391
2423
  // not claim any edit "applied" (transactional parity with Python).
2392
2424
  const validation_failed_idx = new Set<number>();
2393
2425
  for (const { edit, i } of ordered_edits) {
2394
- let single_errors = this.validate_edits([edit], i);
2426
+ let single_errors: string[];
2427
+ try {
2428
+ single_errors = this.validate_edits([edit], i);
2429
+ } catch (e) {
2430
+ // A pathological user pattern must fail as a clean per-edit
2431
+ // validation error, never a hang or crash (QA 2026-07-17 F5).
2432
+ if (!(e instanceof RegexTimeoutError)) throw e;
2433
+ single_errors = [`- Edit ${i + 1} Failed: ${e.message}`];
2434
+ }
2395
2435
  if (single_errors.length > 0) {
2396
2436
  if (applied_edits > 0) {
2397
2437
  const hint = sequential_context_hint(applied_edits);
@@ -2489,7 +2529,14 @@ export class RedlineEngine {
2489
2529
  const sequential_errors: string[] = [];
2490
2530
  let applied_so_far = 0;
2491
2531
  for (const { edit, i } of ordered_edits) {
2492
- let single_errors = this.validate_edits([edit], i);
2532
+ let single_errors: string[];
2533
+ try {
2534
+ single_errors = this.validate_edits([edit], i);
2535
+ } catch (e) {
2536
+ // Clean per-edit failure for time-budget violations (QA F5).
2537
+ if (!(e instanceof RegexTimeoutError)) throw e;
2538
+ single_errors = [`- Edit ${i + 1} Failed: ${e.message}`];
2539
+ }
2493
2540
  if (single_errors.length > 0) {
2494
2541
  if (applied_so_far > 0) {
2495
2542
  const hint = sequential_context_hint(applied_so_far);
@@ -2624,7 +2671,20 @@ export class RedlineEngine {
2624
2671
  edit._error_msg = msg;
2625
2672
  }
2626
2673
  } else {
2627
- const resolved = this._pre_resolve_heuristic_edit(edit);
2674
+ let resolved: any;
2675
+ try {
2676
+ resolved = this._pre_resolve_heuristic_edit(edit);
2677
+ } catch (e) {
2678
+ // Direct apply_edits callers bypass validate_edits; the time
2679
+ // budget must still fail cleanly here (QA F5).
2680
+ if (!(e instanceof RegexTimeoutError)) throw e;
2681
+ skipped++;
2682
+ edit._applied_status = false;
2683
+ const msg = `- Failed to apply edit targeting: '${(edit.target_text || "").substring(0, 40)}...' (${e.message})`;
2684
+ this.skipped_details.push(msg);
2685
+ edit._error_msg = msg;
2686
+ continue;
2687
+ }
2628
2688
  if (resolved) {
2629
2689
  if (Array.isArray(resolved)) {
2630
2690
  for (const r of resolved) {
@@ -2667,8 +2727,8 @@ export class RedlineEngine {
2667
2727
 
2668
2728
  // Snapshot preview context now, while every resolved offset still refers
2669
2729
  // to the untouched document. The sweep below mutates the DOM and rebuilds
2670
- // the map, which shifts offsets and injects tracked-change markup —
2671
- // slicing full_text at report time garbled previews (QA H1).
2730
+ // the map, shifting offsets and injecting tracked-change markup —
2731
+ // slicing full_text at report time garbles previews (QA H1).
2672
2732
  for (const [res_edit] of resolved_edits) {
2673
2733
  this._capture_preview_context(res_edit);
2674
2734
  if (res_edit._parent_edit_ref) {
@@ -2814,6 +2874,29 @@ export class RedlineEngine {
2814
2874
  continue;
2815
2875
  }
2816
2876
 
2877
+ // Refuse accept/reject on a w:id shared by revisions from DIFFERENT
2878
+ // authors. Uniqueness of w:id is assumed but not guaranteed for
2879
+ // externally produced documents (merges, cross-document copy-paste),
2880
+ // where one action would silently resolve several unrelated changes
2881
+ // (QA 2026-07-17 F9). Same-author reuse is legitimate — this engine
2882
+ // itself mints one id across every element of a single logical edit —
2883
+ // so authorship is the discriminator.
2884
+ const dup_authors = Array.from(
2885
+ new Set(all_nodes.map((n) => n.getAttribute("w:author") || "Unknown")),
2886
+ ).sort();
2887
+ if (dup_authors.length > 1) {
2888
+ skipped++;
2889
+ this.skipped_details.push(
2890
+ `- Failed to apply action: ${type} on Chg:${target_id} is ambiguous. The document ` +
2891
+ `contains ${all_nodes.length} tracked-change elements sharing w:id=${target_id} from ` +
2892
+ `different authors (${dup_authors.join(", ")}) — duplicate revision IDs produced ` +
2893
+ `outside this engine (e.g. by a document merge or copy-paste). Acting on this ID ` +
2894
+ `would resolve all of them at once. Resolve these changes individually in Word, or ` +
2895
+ `apply the intended outcome as an explicit text edit instead.`,
2896
+ );
2897
+ continue;
2898
+ }
2899
+
2817
2900
  for (const node of all_nodes) {
2818
2901
  const is_ins = node.tagName === "w:ins";
2819
2902
  const parent_tag = node.parentNode
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,
@@ -879,11 +880,16 @@ export class DocumentMapper {
879
880
  is_regex: boolean = false,
880
881
  ): [number, number] {
881
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.
882
887
  try {
883
- const pattern = new RegExp(target_text);
884
- const match = pattern.exec(this.full_text);
885
- if (match) return [match.index, match[0].length];
886
- } 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
+ }
887
893
  return [-1, 0];
888
894
  }
889
895
 
@@ -923,12 +929,13 @@ export class DocumentMapper {
923
929
  if (!target_text) return [];
924
930
 
925
931
  if (is_regex) {
932
+ // Budgeted like find_match_index above (QA 2026-07-17 F5).
926
933
  try {
927
- const pattern = new RegExp(target_text, "g");
928
- const matches = [...this.full_text.matchAll(pattern)];
929
- if (matches.length > 0)
930
- return matches.map((m) => [m.index!, m[0].length]);
931
- } 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
+ }
932
939
  return [];
933
940
  }
934
941
  // Exact tiers use plain indexOf scans, NOT RegExp: building a RegExp from
@@ -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
  });
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
  );