@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/dist/index.cjs +169 -18
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +43 -5
- package/dist/index.d.ts +43 -5
- package/dist/index.js +162 -17
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
- package/src/domain.ts +10 -1
- package/src/engine.ts +103 -20
- package/src/index.ts +3 -2
- package/src/mapper.ts +16 -9
- package/src/markup.test.ts +2 -1
- package/src/markup.ts +3 -1
- package/src/repro_qa_report_v5.test.ts +399 -0
- package/src/sanitize/transforms.ts +26 -2
- package/src/utils/safe-regex.ts +90 -0
package/package.json
CHANGED
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.
|
|
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,
|
|
575
|
-
*
|
|
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
|
|
694
|
-
* empty string
|
|
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
|
|
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
|
-
//
|
|
2301
|
-
//
|
|
2302
|
-
//
|
|
2303
|
-
//
|
|
2304
|
-
//
|
|
2305
|
-
//
|
|
2306
|
-
//
|
|
2307
|
-
//
|
|
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
|
|
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
|
|
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
|
-
|
|
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,
|
|
2671
|
-
// slicing full_text at report time
|
|
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
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
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
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
}
|
|
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
|
package/src/markup.test.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
);
|