@adeu/core 1.20.0 → 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/dist/index.cjs +414 -46
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +66 -0
- package/dist/index.d.ts +66 -0
- package/dist/index.js +414 -46
- package/dist/index.js.map +1 -1
- package/package.json +1 -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 +407 -25
- package/src/mapper.ts +112 -19
- package/src/markup.test.ts +1 -1
- package/src/markup.ts +11 -5
- package/src/repro_qa_report_v3.test.ts +9 -3
- package/src/utils/text.ts +26 -0
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
|
-
|
|
855
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
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
|
-
|
|
880
|
-
if (
|
|
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 [];
|
package/src/markup.test.ts
CHANGED
|
@@ -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('
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
437
|
-
|
|
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");
|
|
@@ -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;
|
|
@@ -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
|
+
}
|