@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/dist/index.cjs +577 -58
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +105 -1
- package/dist/index.d.ts +105 -1
- package/dist/index.js +570 -57
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
- package/src/domain.ts +10 -1
- package/src/engine.bugs.test.ts +1 -1
- package/src/engine.qa_v4_port.test.ts +304 -0
- package/src/engine.sequential.test.ts +140 -0
- package/src/engine.ts +501 -36
- package/src/index.ts +3 -2
- package/src/mapper.ts +128 -28
- package/src/markup.test.ts +3 -2
- package/src/markup.ts +14 -6
- package/src/repro_qa_report_v3.test.ts +9 -3
- 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/src/utils/text.ts +26 -0
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
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
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
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
}
|
|
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
|
-
|
|
855
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
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
|
-
|
|
880
|
-
if (
|
|
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 [];
|
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
|
});
|
|
@@ -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('
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
437
|
-
|
|
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
|
-
|
|
176
|
-
|
|
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;
|