@adeu/core 1.18.0 → 1.18.2
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 +68 -23
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +19 -0
- package/dist/index.d.ts +19 -0
- package/dist/index.js +68 -23
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
- package/src/engine.comment-preservation.test.ts +174 -0
- package/src/engine.feedback.test.ts +34 -2
- package/src/engine.ts +106 -30
package/package.json
CHANGED
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
// FILE: node/packages/core/src/engine.comment-preservation.test.ts
|
|
2
|
+
import { describe, it, expect } from "vitest";
|
|
3
|
+
import { zipSync, strToU8 } from "fflate";
|
|
4
|
+
import { DocumentObject } from "./docx/bridge.js";
|
|
5
|
+
import { extract_comments_data } from "./comments.js";
|
|
6
|
+
import { RedlineEngine } from "./engine.js";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Regression test for author-aware comment preservation on accept/reject.
|
|
10
|
+
*
|
|
11
|
+
* Bug: accepting a tracked change whose run is wrapped by a comment caused
|
|
12
|
+
* `_clean_wrapping_comments` to delete that comment unconditionally — even when
|
|
13
|
+
* it belonged to another author. In the playbook-commenting scenario this
|
|
14
|
+
* silently erased the counterparty's ("Supplier's Counsel") annotation the
|
|
15
|
+
* instant their insertion (Chg:2) was accepted, destroying provenance and
|
|
16
|
+
* failing the "keep the counterparty comment" requirement.
|
|
17
|
+
*
|
|
18
|
+
* Fix: a wrapping comment authored by someone else has its range markers
|
|
19
|
+
* detached (so the accept proceeds with no orphaned anchor) but its BODY is
|
|
20
|
+
* kept in the comments part. Own-authored wrapping comments are still deleted.
|
|
21
|
+
*
|
|
22
|
+
* The triggering document is built in-memory as a minimal valid .docx so the
|
|
23
|
+
* test doesn't depend on the contents of any golden fixture: a paragraph with a
|
|
24
|
+
* foreign <w:ins id="2"> whose run is wrapped by comment id="1" from that same
|
|
25
|
+
* foreign author.
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
const WORD_XMLNS =
|
|
29
|
+
'xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main" ' +
|
|
30
|
+
'xmlns:w14="http://schemas.microsoft.com/office/word/2010/wordml" ' +
|
|
31
|
+
'xmlns:w15="http://schemas.microsoft.com/office/word/2012/wordml"';
|
|
32
|
+
|
|
33
|
+
function xmlDecl(body: string): string {
|
|
34
|
+
return '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>\n' + body;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Build a minimal, valid DOCX buffer containing a wrapped foreign insertion:
|
|
39
|
+
*
|
|
40
|
+
* commentRangeStart(1) → <w:ins id=2 author=insAuthor>run("INSERTED")</w:ins>
|
|
41
|
+
* → commentRangeEnd(1) → <w:r><w:commentReference id=1/></w:r>
|
|
42
|
+
*
|
|
43
|
+
* plus a comments part with one comment id=1 authored by commentAuthor, body
|
|
44
|
+
* "robust protection". Includes [Content_Types].xml (with the comments
|
|
45
|
+
* Override) and word/_rels/document.xml.rels so `load()` classifies the parts.
|
|
46
|
+
*/
|
|
47
|
+
async function buildWrappedInsertionDoc(
|
|
48
|
+
insAuthor: string,
|
|
49
|
+
commentAuthor: string,
|
|
50
|
+
): Promise<DocumentObject> {
|
|
51
|
+
const contentTypes = xmlDecl(
|
|
52
|
+
`<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types">
|
|
53
|
+
<Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/>
|
|
54
|
+
<Default Extension="xml" ContentType="application/xml"/>
|
|
55
|
+
<Override PartName="/word/document.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.document.main+xml"/>
|
|
56
|
+
<Override PartName="/word/comments.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.comments+xml"/>
|
|
57
|
+
</Types>`,
|
|
58
|
+
);
|
|
59
|
+
|
|
60
|
+
const rootRels = xmlDecl(
|
|
61
|
+
`<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">
|
|
62
|
+
<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="word/document.xml"/>
|
|
63
|
+
</Relationships>`,
|
|
64
|
+
);
|
|
65
|
+
|
|
66
|
+
const documentRels = xmlDecl(
|
|
67
|
+
`<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">
|
|
68
|
+
<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/comments" Target="comments.xml"/>
|
|
69
|
+
</Relationships>`,
|
|
70
|
+
);
|
|
71
|
+
|
|
72
|
+
const documentXml = xmlDecl(
|
|
73
|
+
`<w:document ${WORD_XMLNS}>
|
|
74
|
+
<w:body>
|
|
75
|
+
<w:p w14:paraId="00000001">
|
|
76
|
+
<w:r><w:t xml:space="preserve">Prefix text. </w:t></w:r>
|
|
77
|
+
<w:commentRangeStart w:id="1"/>
|
|
78
|
+
<w:ins w:id="2" w:author="${insAuthor}" w:date="2026-01-01T00:00:00Z"><w:r><w:t>INSERTED</w:t></w:r></w:ins>
|
|
79
|
+
<w:commentRangeEnd w:id="1"/>
|
|
80
|
+
<w:r><w:rPr><w:rStyle w:val="CommentReference"/></w:rPr><w:commentReference w:id="1"/></w:r>
|
|
81
|
+
<w:r><w:t xml:space="preserve"> suffix text.</w:t></w:r>
|
|
82
|
+
</w:p>
|
|
83
|
+
</w:body>
|
|
84
|
+
</w:document>`,
|
|
85
|
+
);
|
|
86
|
+
|
|
87
|
+
const commentsXml = xmlDecl(
|
|
88
|
+
`<w:comments ${WORD_XMLNS}>
|
|
89
|
+
<w:comment w:id="1" w:author="${commentAuthor}" w:date="2026-01-01T00:00:00Z" w:initials="SC"><w:p><w:r><w:t>robust protection</w:t></w:r></w:p></w:comment>
|
|
90
|
+
</w:comments>`,
|
|
91
|
+
);
|
|
92
|
+
|
|
93
|
+
const zip: Record<string, Uint8Array> = {
|
|
94
|
+
"[Content_Types].xml": strToU8(contentTypes),
|
|
95
|
+
"_rels/.rels": strToU8(rootRels),
|
|
96
|
+
"word/document.xml": strToU8(documentXml),
|
|
97
|
+
"word/comments.xml": strToU8(commentsXml),
|
|
98
|
+
"word/_rels/document.xml.rels": strToU8(documentRels),
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
const buf = Buffer.from(zipSync(zip));
|
|
102
|
+
return DocumentObject.load(buf);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
describe("author-aware wrapping-comment preservation", () => {
|
|
106
|
+
it("keeps a foreign author's wrapping comment when their change is accepted", async () => {
|
|
107
|
+
const doc = await buildWrappedInsertionDoc(
|
|
108
|
+
"Supplier's Counsel",
|
|
109
|
+
"Supplier's Counsel",
|
|
110
|
+
);
|
|
111
|
+
|
|
112
|
+
// Sanity: comment present before we touch anything.
|
|
113
|
+
const before = extract_comments_data(doc.pkg);
|
|
114
|
+
expect(Object.keys(before).length).toBe(1);
|
|
115
|
+
expect(before["1"].text).toContain("robust protection");
|
|
116
|
+
|
|
117
|
+
// We ("Authority Counsel") accept the counterparty's insertion Chg:2.
|
|
118
|
+
const engine = new RedlineEngine(doc, "Authority Counsel");
|
|
119
|
+
engine.process_batch(
|
|
120
|
+
[{ type: "accept", target_id: "Chg:2" } as any],
|
|
121
|
+
false,
|
|
122
|
+
);
|
|
123
|
+
|
|
124
|
+
// The counterparty's comment body must survive the accept.
|
|
125
|
+
const after = extract_comments_data(doc.pkg);
|
|
126
|
+
expect(after["1"]?.text).toContain("robust protection");
|
|
127
|
+
|
|
128
|
+
// The accept must actually have taken effect: the <w:ins> is unwrapped.
|
|
129
|
+
const savedBuf = await doc.save();
|
|
130
|
+
const reloaded = await DocumentObject.load(savedBuf);
|
|
131
|
+
expect(reloaded.element.getElementsByTagName("w:ins").length).toBe(0);
|
|
132
|
+
|
|
133
|
+
// Comment survives the roundtrip too.
|
|
134
|
+
const afterRoundtrip = extract_comments_data(reloaded.pkg);
|
|
135
|
+
expect(afterRoundtrip["1"]?.text).toContain("robust protection");
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
it("keeps a foreign author's wrapping comment when their change is rejected", async () => {
|
|
139
|
+
const doc = await buildWrappedInsertionDoc(
|
|
140
|
+
"Supplier's Counsel",
|
|
141
|
+
"Supplier's Counsel",
|
|
142
|
+
);
|
|
143
|
+
|
|
144
|
+
const engine = new RedlineEngine(doc, "Authority Counsel");
|
|
145
|
+
engine.process_batch(
|
|
146
|
+
[{ type: "reject", target_id: "Chg:2" } as any],
|
|
147
|
+
false,
|
|
148
|
+
);
|
|
149
|
+
|
|
150
|
+
// Rejecting removes the inserted TEXT, but the wrapping annotation (a
|
|
151
|
+
// separate comment) is preserved rather than collaterally deleted.
|
|
152
|
+
const after = extract_comments_data(doc.pkg);
|
|
153
|
+
expect(after["1"]?.text).toContain("robust protection");
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
it("still deletes our OWN wrapping comment when we accept our own change", async () => {
|
|
157
|
+
// Control: when the wrapping comment is ours, cleanup is unchanged — it is
|
|
158
|
+
// removed, matching prior behavior (keeping our own now-resolved note is not
|
|
159
|
+
// required).
|
|
160
|
+
const doc = await buildWrappedInsertionDoc(
|
|
161
|
+
"Authority Counsel",
|
|
162
|
+
"Authority Counsel",
|
|
163
|
+
);
|
|
164
|
+
|
|
165
|
+
const engine = new RedlineEngine(doc, "Authority Counsel");
|
|
166
|
+
engine.process_batch(
|
|
167
|
+
[{ type: "accept", target_id: "Chg:2" } as any],
|
|
168
|
+
false,
|
|
169
|
+
);
|
|
170
|
+
|
|
171
|
+
const after = extract_comments_data(doc.pkg);
|
|
172
|
+
expect(after["1"]).toBeUndefined();
|
|
173
|
+
});
|
|
174
|
+
});
|
|
@@ -31,7 +31,11 @@ describe("Feedback Layer & Dry Run Verification", () => {
|
|
|
31
31
|
expect(stats.version).toBeDefined();
|
|
32
32
|
});
|
|
33
33
|
|
|
34
|
-
it("punctuation anchor
|
|
34
|
+
it("punctuation anchor: no warning on clean apply", async () => {
|
|
35
|
+
// A punctuated anchor that matches and applies cleanly must NOT raise the
|
|
36
|
+
// tokenization-splitting warning. The redline preview already reports the
|
|
37
|
+
// change; a warning here is a false positive that pushes agents into
|
|
38
|
+
// needless "cleaner anchor" retries.
|
|
35
39
|
const doc = await createTestDocument();
|
|
36
40
|
addParagraph(doc, "Refer to sample_term_name in Section 4.");
|
|
37
41
|
const engine = new RedlineEngine(doc, "Reviewer TS");
|
|
@@ -41,9 +45,37 @@ describe("Feedback Layer & Dry Run Verification", () => {
|
|
|
41
45
|
]);
|
|
42
46
|
|
|
43
47
|
const report = stats.edits[0];
|
|
48
|
+
expect(report.status).toBe("applied");
|
|
49
|
+
expect(report.warning).toBeNull();
|
|
50
|
+
|
|
51
|
+
// Same expectation under dry_run.
|
|
52
|
+
const doc2 = await createTestDocument();
|
|
53
|
+
addParagraph(doc2, "Refer to sample_term_name in Section 4.");
|
|
54
|
+
const engine2 = new RedlineEngine(doc2, "Reviewer TS");
|
|
55
|
+
const dryStats = (engine2 as any).process_batch([
|
|
56
|
+
{ type: "modify", target_text: "sample_term_name", new_text: "validated_term_name" }
|
|
57
|
+
], true);
|
|
58
|
+
const dryReport = dryStats.edits[0];
|
|
59
|
+
expect(dryReport.status).toBe("applied");
|
|
60
|
+
expect(dryReport.warning).toBeNull();
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it("punctuation anchor: warns only when match fails", async () => {
|
|
64
|
+
// When a punctuated anchor fails to match, the warning IS surfaced as
|
|
65
|
+
// recovery context (the punctuation may be why the match missed).
|
|
66
|
+
const doc = await createTestDocument();
|
|
67
|
+
addParagraph(doc, "Refer to sample_term_name in Section 4.");
|
|
68
|
+
const engine = new RedlineEngine(doc, "Reviewer TS");
|
|
69
|
+
|
|
70
|
+
const stats = (engine as any).process_batch([
|
|
71
|
+
{ type: "modify", target_text: "phantom_term-x", new_text: "anything" }
|
|
72
|
+
], true);
|
|
73
|
+
|
|
74
|
+
const report = stats.edits[0];
|
|
75
|
+
expect(report.status).toBe("failed");
|
|
44
76
|
expect(report.warning).not.toBeNull();
|
|
45
77
|
expect(report.warning.toLowerCase()).toContain("punctuation");
|
|
46
|
-
expect(report.warning).toContain("
|
|
78
|
+
expect(report.warning).toContain("phantom_term-x");
|
|
47
79
|
});
|
|
48
80
|
|
|
49
81
|
it("dry_run does not mutate and reports safely", async () => {
|
package/src/engine.ts
CHANGED
|
@@ -65,7 +65,6 @@ function insertAtIndex(parent: Element, index: number, child: Node) {
|
|
|
65
65
|
}
|
|
66
66
|
}
|
|
67
67
|
|
|
68
|
-
|
|
69
68
|
function safeCloneEdit(val: any, seen: WeakMap<any, any> = new WeakMap()): any {
|
|
70
69
|
if (val === null || typeof val !== "object") {
|
|
71
70
|
return val;
|
|
@@ -127,7 +126,10 @@ function restoreSnapshot(doc: any, snapshot: any): void {
|
|
|
127
126
|
}
|
|
128
127
|
}
|
|
129
128
|
|
|
130
|
-
function stripMatchingHeadingHashes(
|
|
129
|
+
function stripMatchingHeadingHashes(
|
|
130
|
+
target: string,
|
|
131
|
+
newText: string,
|
|
132
|
+
): [string, string] {
|
|
131
133
|
if (!target || !newText) return [target, newText];
|
|
132
134
|
const targetMatch = target.match(/^(#+)\s+/);
|
|
133
135
|
const newMatch = newText.match(/^(#+)\s+/);
|
|
@@ -150,7 +152,10 @@ export class BatchValidationError extends Error {
|
|
|
150
152
|
}
|
|
151
153
|
}
|
|
152
154
|
|
|
153
|
-
export function validate_edit_strings(
|
|
155
|
+
export function validate_edit_strings(
|
|
156
|
+
edits: any[],
|
|
157
|
+
index_offset: number = 0,
|
|
158
|
+
): string[] {
|
|
154
159
|
const errors: string[] = [];
|
|
155
160
|
|
|
156
161
|
for (let i = 0; i < edits.length; i++) {
|
|
@@ -322,8 +327,19 @@ export class RedlineEngine {
|
|
|
322
327
|
this.comments_manager = new CommentsManager(this.doc);
|
|
323
328
|
}
|
|
324
329
|
|
|
330
|
+
/**
|
|
331
|
+
* Return a hint when a short, single-token anchor contains punctuation that
|
|
332
|
+
* can split awkwardly, else null.
|
|
333
|
+
*
|
|
334
|
+
* Surface this ONLY for edits that actually failed to match/apply. On a
|
|
335
|
+
* successful edit the batch report already carries the redline preview, so
|
|
336
|
+
* emitting this would be a false positive: the punctuation (dates,
|
|
337
|
+
* `[_name_]` placeholders, `____` blanks) is frequently the literal target
|
|
338
|
+
* and the edit succeeds despite it. Mirrors the Python engine.
|
|
339
|
+
*/
|
|
325
340
|
private _check_punctuation_warning(target_text: string): string | null {
|
|
326
341
|
if (!target_text) return null;
|
|
342
|
+
if (target_text.length > 20 || target_text.includes(" ")) return null;
|
|
327
343
|
if (target_text.includes("_") || target_text.includes("-")) {
|
|
328
344
|
return `Warning: target_text '${target_text}' contains tokenization-splitting punctuation ('_' or '-'). This can trigger mid-word splits in the diff engine. Consider using a longer plain-prose anchor.`;
|
|
329
345
|
}
|
|
@@ -386,7 +402,8 @@ export class RedlineEngine {
|
|
|
386
402
|
let target_text = edit.target_text || "";
|
|
387
403
|
let new_text = edit.new_text || "";
|
|
388
404
|
|
|
389
|
-
const [clean_target, target_style] =
|
|
405
|
+
const [clean_target, target_style] =
|
|
406
|
+
this._parse_markdown_style(target_text);
|
|
390
407
|
if (target_style && target_style.startsWith("Heading")) {
|
|
391
408
|
const prefix_len = target_text.length - clean_target.length;
|
|
392
409
|
start_idx += prefix_len;
|
|
@@ -1347,7 +1364,29 @@ export class RedlineEngine {
|
|
|
1347
1364
|
|
|
1348
1365
|
insertAfter(ref_run, new_end);
|
|
1349
1366
|
}
|
|
1350
|
-
|
|
1367
|
+
/**
|
|
1368
|
+
* Read a comment's author directly from the comments part. Used by
|
|
1369
|
+
* _clean_wrapping_comments to decide whether a wrapping comment belongs to
|
|
1370
|
+
* another author (and must be preserved when its anchored change is
|
|
1371
|
+
* accepted/rejected) or to us (safe to delete). Reads the part rather than
|
|
1372
|
+
* the mapper because the mapper's comments_map is not rebuilt between review
|
|
1373
|
+
* actions, so it can be stale mid-batch. Returns null if not found.
|
|
1374
|
+
*/
|
|
1375
|
+
private _get_comment_author(c_id: string): string | null {
|
|
1376
|
+
const part = this.doc.pkg.parts.find(
|
|
1377
|
+
(p) =>
|
|
1378
|
+
p.contentType ===
|
|
1379
|
+
"application/vnd.openxmlformats-officedocument.wordprocessingml.comments+xml",
|
|
1380
|
+
);
|
|
1381
|
+
if (!part) return null;
|
|
1382
|
+
const comments = findAllDescendants(part._element, "w:comment");
|
|
1383
|
+
for (const c of comments) {
|
|
1384
|
+
if (c.getAttribute("w:id") === c_id) {
|
|
1385
|
+
return c.getAttribute("w:author");
|
|
1386
|
+
}
|
|
1387
|
+
}
|
|
1388
|
+
return null;
|
|
1389
|
+
}
|
|
1351
1390
|
private _clean_wrapping_comments(element: Element) {
|
|
1352
1391
|
let first_node: Element = element;
|
|
1353
1392
|
while (true) {
|
|
@@ -1420,7 +1459,21 @@ export class RedlineEngine {
|
|
|
1420
1459
|
for (const s of starts_to_remove) {
|
|
1421
1460
|
const c_id = s.getAttribute("w:id");
|
|
1422
1461
|
if (c_id && end_ids.has(c_id)) {
|
|
1423
|
-
|
|
1462
|
+
// Author-aware preservation. A comment that merely WRAPS the change
|
|
1463
|
+
// being accepted/rejected is a separate annotation — often the
|
|
1464
|
+
// counterparty's note explaining the very edit we are resolving.
|
|
1465
|
+
// Deleting it silently destroys their provenance (and, e.g., fails a
|
|
1466
|
+
// "keep the counterparty's comment" review requirement). So: always
|
|
1467
|
+
// detach the range markers (leaving no orphaned anchor, which lets the
|
|
1468
|
+
// accept/reject proceed cleanly), but only delete the comment BODY when
|
|
1469
|
+
// it is ours. Foreign-authored comment bodies are kept in the comments
|
|
1470
|
+
// part. When the author can't be read we default to preserving, since
|
|
1471
|
+
// deletion is the irreversible, higher-cost mistake.
|
|
1472
|
+
const author = this._get_comment_author(c_id);
|
|
1473
|
+
const is_own = author !== null && author === this.author;
|
|
1474
|
+
if (is_own) {
|
|
1475
|
+
this.comments_manager.deleteComment(c_id);
|
|
1476
|
+
}
|
|
1424
1477
|
if (s.parentNode) s.parentNode.removeChild(s);
|
|
1425
1478
|
for (const e of ends_to_remove) {
|
|
1426
1479
|
let e_id: string | null = null;
|
|
@@ -1572,10 +1625,9 @@ export class RedlineEngine {
|
|
|
1572
1625
|
if (edit.target_text.includes("|")) {
|
|
1573
1626
|
matches = matches.slice(0, 1);
|
|
1574
1627
|
} else {
|
|
1575
|
-
const positions: [number, number][] = matches.map(
|
|
1576
|
-
start,
|
|
1577
|
-
|
|
1578
|
-
]);
|
|
1628
|
+
const positions: [number, number][] = matches.map(
|
|
1629
|
+
([start, length]) => [start, start + length],
|
|
1630
|
+
);
|
|
1579
1631
|
errors.push(
|
|
1580
1632
|
format_ambiguity_error(
|
|
1581
1633
|
i + 1 + index_offset,
|
|
@@ -1810,8 +1862,17 @@ export class RedlineEngine {
|
|
|
1810
1862
|
): any {
|
|
1811
1863
|
// Pre-process edits: strip identical leading heading hashes from target_text and new_text
|
|
1812
1864
|
for (const c of changes) {
|
|
1813
|
-
if (
|
|
1814
|
-
|
|
1865
|
+
if (
|
|
1866
|
+
c &&
|
|
1867
|
+
typeof c === "object" &&
|
|
1868
|
+
(c as any).type === "modify" &&
|
|
1869
|
+
(c as any).target_text &&
|
|
1870
|
+
(c as any).new_text
|
|
1871
|
+
) {
|
|
1872
|
+
const [strippedTarget, strippedNew] = stripMatchingHeadingHashes(
|
|
1873
|
+
(c as any).target_text,
|
|
1874
|
+
(c as any).new_text,
|
|
1875
|
+
);
|
|
1815
1876
|
(c as any).target_text = strippedTarget;
|
|
1816
1877
|
(c as any).new_text = strippedNew;
|
|
1817
1878
|
}
|
|
@@ -1850,10 +1911,7 @@ export class RedlineEngine {
|
|
|
1850
1911
|
actions.length > 0 ? this.validate_review_actions(actions) : [];
|
|
1851
1912
|
const validate_edits_now = edits.length > 0 && action_errors.length > 0;
|
|
1852
1913
|
const edit_errors = validate_edits_now ? this.validate_edits(edits) : [];
|
|
1853
|
-
const all_errors = [
|
|
1854
|
-
...action_errors,
|
|
1855
|
-
...edit_errors,
|
|
1856
|
-
];
|
|
1914
|
+
const all_errors = [...action_errors, ...edit_errors];
|
|
1857
1915
|
if (all_errors.length > 0) {
|
|
1858
1916
|
throw new BatchValidationError(all_errors);
|
|
1859
1917
|
}
|
|
@@ -1894,11 +1952,16 @@ export class RedlineEngine {
|
|
|
1894
1952
|
for (let i = 0; i < edits.length; i++) {
|
|
1895
1953
|
const edit = edits[i];
|
|
1896
1954
|
const single_errors = this.validate_edits([edit], i);
|
|
1897
|
-
const warning = this._check_punctuation_warning(
|
|
1898
|
-
(edit as any).target_text || "",
|
|
1899
|
-
);
|
|
1900
1955
|
if (single_errors.length > 0) {
|
|
1901
1956
|
skipped_edits++;
|
|
1957
|
+
// Only surface the punctuation-anchor warning when the edit actually
|
|
1958
|
+
// failed. A clean apply already returns the redline preview, so the
|
|
1959
|
+
// warning is pure noise on success — and it misleads agents into
|
|
1960
|
+
// hunting for a "cleaner" anchor that was never needed (e.g. on
|
|
1961
|
+
// placeholders/dates where the punctuation IS the literal target).
|
|
1962
|
+
const warning = this._check_punctuation_warning(
|
|
1963
|
+
(edit as any).target_text || "",
|
|
1964
|
+
);
|
|
1902
1965
|
edits_reports.push({
|
|
1903
1966
|
status: "failed",
|
|
1904
1967
|
target_text: (edit as any).target_text || "",
|
|
@@ -1918,7 +1981,7 @@ export class RedlineEngine {
|
|
|
1918
1981
|
status: "applied",
|
|
1919
1982
|
target_text: (edit as any).target_text || "",
|
|
1920
1983
|
new_text: (edit as any).new_text || "",
|
|
1921
|
-
warning:
|
|
1984
|
+
warning: null,
|
|
1922
1985
|
error: null,
|
|
1923
1986
|
critic_markup: previews[0],
|
|
1924
1987
|
clean_text: previews[1],
|
|
@@ -1935,6 +1998,9 @@ export class RedlineEngine {
|
|
|
1935
1998
|
this.skipped_details.length > 0
|
|
1936
1999
|
? this.skipped_details[this.skipped_details.length - 1]
|
|
1937
2000
|
: "Failed to apply edit";
|
|
2001
|
+
const warning = this._check_punctuation_warning(
|
|
2002
|
+
(edit as any).target_text || "",
|
|
2003
|
+
);
|
|
1938
2004
|
edits_reports.push({
|
|
1939
2005
|
status: "failed",
|
|
1940
2006
|
target_text: (edit as any).target_text || "",
|
|
@@ -1975,23 +2041,25 @@ export class RedlineEngine {
|
|
|
1975
2041
|
throw err;
|
|
1976
2042
|
}
|
|
1977
2043
|
|
|
1978
|
-
applied_edits = edits.filter(
|
|
1979
|
-
(e) => (e as any)._applied_status,
|
|
1980
|
-
).length;
|
|
2044
|
+
applied_edits = edits.filter((e) => (e as any)._applied_status).length;
|
|
1981
2045
|
skipped_edits = edits.length - applied_edits;
|
|
1982
2046
|
|
|
1983
2047
|
for (const edit of edits) {
|
|
1984
2048
|
const success = (edit as any)._applied_status || false;
|
|
1985
2049
|
const error_msg = (edit as any)._error_msg || null;
|
|
1986
|
-
const warning = this._check_punctuation_warning(
|
|
1987
|
-
(edit as any).target_text || "",
|
|
1988
|
-
);
|
|
1989
2050
|
let critic_markup = null;
|
|
1990
2051
|
let clean_text = null;
|
|
2052
|
+
// Punctuation-anchor warning is failure-context only: on success the
|
|
2053
|
+
// redline preview below already reports the change cleanly.
|
|
2054
|
+
let warning: string | null = null;
|
|
1991
2055
|
if (success) {
|
|
1992
2056
|
const previews = this._build_edit_context_previews(edit);
|
|
1993
2057
|
critic_markup = previews[0];
|
|
1994
2058
|
clean_text = previews[1];
|
|
2059
|
+
} else {
|
|
2060
|
+
warning = this._check_punctuation_warning(
|
|
2061
|
+
(edit as any).target_text || "",
|
|
2062
|
+
);
|
|
1995
2063
|
}
|
|
1996
2064
|
edits_reports.push({
|
|
1997
2065
|
status: success ? "applied" : "failed",
|
|
@@ -2017,7 +2085,7 @@ export class RedlineEngine {
|
|
|
2017
2085
|
skipped_details: this.skipped_details,
|
|
2018
2086
|
edits: edits_reports,
|
|
2019
2087
|
engine: "node",
|
|
2020
|
-
version: "1.
|
|
2088
|
+
version: "1.18.2",
|
|
2021
2089
|
};
|
|
2022
2090
|
}
|
|
2023
2091
|
|
|
@@ -2562,7 +2630,10 @@ export class RedlineEngine {
|
|
|
2562
2630
|
const t_seg = target_segs[k];
|
|
2563
2631
|
const n_seg = new_segs[k];
|
|
2564
2632
|
if (t_seg !== n_seg) {
|
|
2565
|
-
const [seg_prefix, seg_suffix] = trim_common_context(
|
|
2633
|
+
const [seg_prefix, seg_suffix] = trim_common_context(
|
|
2634
|
+
t_seg,
|
|
2635
|
+
n_seg,
|
|
2636
|
+
);
|
|
2566
2637
|
const seg_target = t_seg.substring(
|
|
2567
2638
|
seg_prefix,
|
|
2568
2639
|
t_seg.length - seg_suffix,
|
|
@@ -2915,7 +2986,8 @@ export class RedlineEngine {
|
|
|
2915
2986
|
const is_inline_first = result.first_node.tagName === "w:ins";
|
|
2916
2987
|
if (is_inline_first) {
|
|
2917
2988
|
if (anchor_run) {
|
|
2918
|
-
const anchor_parent = anchor_run._element
|
|
2989
|
+
const anchor_parent = anchor_run._element
|
|
2990
|
+
.parentNode as Element | null;
|
|
2919
2991
|
if (anchor_parent && anchor_parent.tagName === "w:ins") {
|
|
2920
2992
|
// Inserting inside another author's pending <w:ins>: split it so our
|
|
2921
2993
|
// new <w:ins> lands as a sibling right after the anchor run, never
|
|
@@ -3076,7 +3148,11 @@ export class RedlineEngine {
|
|
|
3076
3148
|
// nested in their <w:ins> and splice our new <w:ins> in right after
|
|
3077
3149
|
// it by splitting their <w:ins>, so we never nest <w:ins> in
|
|
3078
3150
|
// <w:ins>.
|
|
3079
|
-
this._insert_and_split_ins(
|
|
3151
|
+
this._insert_and_split_ins(
|
|
3152
|
+
del_parent,
|
|
3153
|
+
last_del!,
|
|
3154
|
+
result.first_node,
|
|
3155
|
+
);
|
|
3080
3156
|
} else {
|
|
3081
3157
|
// Inline: place the first <w:ins> immediately after last_del.
|
|
3082
3158
|
insertAfter(result.first_node, last_del!);
|