@adeu/core 1.25.0 → 1.26.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/src/mapper.ts CHANGED
@@ -9,6 +9,7 @@ import {
9
9
  get_run_style_markers,
10
10
  get_run_text,
11
11
  is_heading_paragraph,
12
+ split_boundary_whitespace,
12
13
  is_native_heading,
13
14
  iter_block_items,
14
15
  iter_document_parts_with_kind,
@@ -31,6 +32,12 @@ export interface TextSpan {
31
32
  part_index: number;
32
33
  // True for the read-only image marker projection ![alt](docx-image:N).
33
34
  is_image_marker?: boolean;
35
+ // Character offset of this span's text within its run's projected text.
36
+ // One run may back several spans: hoisting boundary whitespace outside
37
+ // style markers (QA 2026-07-19 F-03) projects a bold "The Supplier " run
38
+ // as core + trailing-space spans, and only the first starts at run
39
+ // offset 0. All span->run local-offset arithmetic must add this.
40
+ run_offset?: number;
34
41
  }
35
42
 
36
43
  export function renumber_snapshot_ids(
@@ -411,10 +418,12 @@ export class DocumentMapper {
411
418
  let current_wrappers: [string, string] = ["", ""];
412
419
  let current_style: [string, string] = ["", ""];
413
420
  let active_hyperlink_id: string | null = null;
421
+ // [kind, text, run, run_offset, ins_id, del_id, comment_ids]
414
422
  let pending_runs: [
415
423
  string,
416
424
  string,
417
425
  Run | null,
426
+ number,
418
427
  string | null,
419
428
  string | null,
420
429
  string[],
@@ -427,7 +436,7 @@ export class DocumentMapper {
427
436
  this._add_virtual_text(s_tok, current, paragraph);
428
437
  current += s_tok.length;
429
438
  }
430
- for (const [kind, txt, r_obj, i_id, d_id, c_ids] of pending_runs) {
439
+ for (const [kind, txt, r_obj, r_off, i_id, d_id, c_ids] of pending_runs) {
431
440
  if (kind === "virtual") {
432
441
  this._add_virtual_text(txt, current, paragraph, active_hyperlink_id);
433
442
  } else {
@@ -442,6 +451,7 @@ export class DocumentMapper {
442
451
  hyperlink_id: active_hyperlink_id || undefined,
443
452
  comment_ids: c_ids.length > 0 ? c_ids : undefined,
444
453
  part_index: this._current_part_index,
454
+ run_offset: r_off,
445
455
  };
446
456
  this.spans.push(s);
447
457
  this._text_chunks.push(txt);
@@ -473,7 +483,8 @@ export class DocumentMapper {
473
483
 
474
484
  if (item instanceof Run) {
475
485
  const [prefix, suffix] = get_run_style_markers(item, native_heading);
476
- const run_parts: [string, string, Run | null][] = [];
486
+ // [kind, text, run, run_offset]
487
+ const run_parts: [string, string, Run | null, number][] = [];
477
488
  const text = get_run_text(item);
478
489
 
479
490
  if (leading_strip_active) {
@@ -481,20 +492,50 @@ export class DocumentMapper {
481
492
  leading_strip_active = false;
482
493
  }
483
494
 
495
+ // run_local tracks each real part's offset within the run's projected
496
+ // text, so spans resolve back to exact run positions even when one
497
+ // run backs several spans.
498
+ let run_local = 0;
499
+ const append_wrapped = (segment: string): void => {
500
+ // Boundary whitespace stays OUTSIDE the style markers —
501
+ // `**The Supplier **` is malformed Markdown (QA 2026-07-19 F-03).
502
+ // Must mirror apply_formatting_to_segments exactly (the Virtual
503
+ // Text contract).
504
+ const [lead, core, trail] = split_boundary_whitespace(segment);
505
+ if (!core) {
506
+ run_parts.push(["real", segment, item, run_local]);
507
+ run_local += segment.length;
508
+ return;
509
+ }
510
+ if (lead) {
511
+ run_parts.push(["real", lead, item, run_local]);
512
+ run_local += lead.length;
513
+ }
514
+ if (prefix) run_parts.push(["virtual", prefix, null, 0]);
515
+ run_parts.push(["real", core, item, run_local]);
516
+ run_local += core.length;
517
+ if (suffix) run_parts.push(["virtual", suffix, null, 0]);
518
+ if (trail) {
519
+ run_parts.push(["real", trail, item, run_local]);
520
+ run_local += trail.length;
521
+ }
522
+ };
523
+
484
524
  if (text.includes("\n") && (prefix || suffix)) {
485
525
  const parts = text.split("\n");
486
526
  for (let idx = 0; idx < parts.length; idx++) {
487
- if (idx > 0) run_parts.push(["real", "\n", item]);
488
- if (parts[idx]) {
489
- if (prefix) run_parts.push(["virtual", prefix, null]);
490
- run_parts.push(["real", parts[idx], item]);
491
- if (suffix) run_parts.push(["virtual", suffix, null]);
527
+ if (idx > 0) {
528
+ run_parts.push(["real", "\n", item, run_local]);
529
+ run_local += 1;
492
530
  }
531
+ if (parts[idx]) append_wrapped(parts[idx]);
493
532
  }
533
+ } else if ((prefix || suffix) && text) {
534
+ append_wrapped(text);
494
535
  } else {
495
- if (prefix) run_parts.push(["virtual", prefix, null]);
496
- if (text) run_parts.push(["real", text, item]);
497
- if (suffix) run_parts.push(["virtual", suffix, null]);
536
+ if (prefix) run_parts.push(["virtual", prefix, null, 0]);
537
+ if (text) run_parts.push(["real", text, item, 0]);
538
+ if (suffix) run_parts.push(["virtual", suffix, null, 0]);
498
539
  }
499
540
 
500
541
  if (this.clean_view && Object.keys(active_del).length > 0) {
@@ -542,7 +583,7 @@ export class DocumentMapper {
542
583
  }
543
584
 
544
585
  const curr_comment_ids = Array.from(active_ids);
545
- for (const [kind, txt, r_obj] of run_parts) {
586
+ for (const [kind, txt, r_obj, r_off] of run_parts) {
546
587
  if (
547
588
  skip_leading_prefix &&
548
589
  kind === "virtual" &&
@@ -555,6 +596,7 @@ export class DocumentMapper {
555
596
  kind,
556
597
  txt,
557
598
  r_obj,
599
+ r_off,
558
600
  curr_ins_id,
559
601
  curr_del_id,
560
602
  curr_comment_ids,
@@ -566,11 +608,12 @@ export class DocumentMapper {
566
608
  current_wrappers = new_wrappers;
567
609
  current_style = new_style;
568
610
  const curr_comment_ids = Array.from(active_ids);
569
- for (const [kind, txt, r_obj] of run_parts) {
611
+ for (const [kind, txt, r_obj, r_off] of run_parts) {
570
612
  pending_runs.push([
571
613
  kind,
572
614
  txt,
573
615
  r_obj,
616
+ r_off,
574
617
  curr_ins_id,
575
618
  curr_del_id,
576
619
  curr_comment_ids,
@@ -1108,50 +1151,59 @@ export class DocumentMapper {
1108
1151
  );
1109
1152
  if (affected_spans.length === 0) return [];
1110
1153
 
1111
- const working_runs = affected_spans
1112
- .filter((s) => s.run !== null)
1113
- .map((s) => s.run!);
1114
- if (working_runs.length === 0) return [];
1154
+ const real_spans = affected_spans.filter((s) => s.run !== null);
1155
+ if (real_spans.length === 0) return [];
1156
+
1157
+ // One run may back several spans (boundary whitespace hoisted outside
1158
+ // style markers projects a run as lead/core/trail spans, QA 2026-07-19
1159
+ // F-03): deduplicate by identity or the run would be split and wrapped
1160
+ // once per span.
1161
+ const working_runs: Run[] = [];
1162
+ for (const s of real_spans) {
1163
+ if (!working_runs.some((r) => r === s.run)) {
1164
+ working_runs.push(s.run!);
1165
+ }
1166
+ }
1115
1167
 
1116
1168
  let dom_modified = false;
1117
1169
 
1118
- const first_real_span = affected_spans.find((s) => s.run !== null);
1170
+ // 1. Start Split all local offsets are run-relative: span-relative
1171
+ // position plus the span's own offset within the run.
1172
+ const first_real_span = real_spans[0];
1119
1173
  let start_split_adjustment = 0;
1120
1174
 
1121
- if (first_real_span) {
1122
- const local_start = start_idx - first_real_span.start;
1123
- if (local_start > 0) {
1124
- const idx_in_working = 0;
1125
- const [, right_run] = this._split_run_at_index(
1126
- working_runs[idx_in_working],
1127
- local_start,
1128
- );
1129
- working_runs[idx_in_working] = right_run;
1130
- dom_modified = true;
1131
- start_split_adjustment = local_start;
1175
+ const local_start =
1176
+ start_idx - first_real_span.start + (first_real_span.run_offset || 0);
1177
+ if (local_start > 0) {
1178
+ const split_source = working_runs[0];
1179
+ const [, right_run] = this._split_run_at_index(
1180
+ split_source,
1181
+ local_start,
1182
+ );
1183
+ for (let w = 0; w < working_runs.length; w++) {
1184
+ if (working_runs[w] === split_source) working_runs[w] = right_run;
1132
1185
  }
1186
+ dom_modified = true;
1187
+ start_split_adjustment = local_start;
1133
1188
  }
1134
1189
 
1135
- const last_real_span = [...affected_spans]
1136
- .reverse()
1137
- .find((s) => s.run !== null);
1138
-
1139
- if (last_real_span) {
1140
- const is_same_run = first_real_span === last_real_span;
1141
- const run_to_split = working_runs[working_runs.length - 1];
1142
- let overlap_end = Math.min(last_real_span.end, end_idx);
1143
- let local_end = overlap_end - last_real_span.start;
1190
+ // 2. End Split
1191
+ const last_real_span = real_spans[real_spans.length - 1];
1192
+ const is_same_run = first_real_span.run === last_real_span.run;
1193
+ const run_to_split = working_runs[working_runs.length - 1];
1194
+ const overlap_end = Math.min(last_real_span.end, end_idx);
1195
+ let local_end =
1196
+ overlap_end - last_real_span.start + (last_real_span.run_offset || 0);
1144
1197
 
1145
- if (is_same_run && start_split_adjustment > 0) {
1146
- local_end -= start_split_adjustment;
1147
- }
1198
+ if (is_same_run && start_split_adjustment > 0) {
1199
+ local_end -= start_split_adjustment;
1200
+ }
1148
1201
 
1149
- const run_text = get_run_text(run_to_split);
1150
- if (local_end > 0 && local_end < run_text.length) {
1151
- const [left_run] = this._split_run_at_index(run_to_split, local_end);
1152
- working_runs[working_runs.length - 1] = left_run;
1153
- dom_modified = true;
1154
- }
1202
+ const run_text = get_run_text(run_to_split);
1203
+ if (local_end > 0 && local_end < run_text.length) {
1204
+ const [left_run] = this._split_run_at_index(run_to_split, local_end);
1205
+ working_runs[working_runs.length - 1] = left_run;
1206
+ dom_modified = true;
1155
1207
  }
1156
1208
 
1157
1209
  if (dom_modified && rebuild_map) {
@@ -1206,7 +1258,7 @@ export class DocumentMapper {
1206
1258
  }
1207
1259
  return [null, span.paragraph];
1208
1260
  } else {
1209
- const offset = index - span.start;
1261
+ const offset = index - span.start + (span.run_offset || 0);
1210
1262
  const [left] = this._split_run_at_index(span.run, offset);
1211
1263
  if (rebuild_map) this._build_map();
1212
1264
  return [left, span.paragraph];
@@ -0,0 +1,380 @@
1
+ // FILE: node/packages/core/src/repro_qa_report_v7.test.ts
2
+ /**
3
+ * Node-engine repro tests for the 2026-07-19 black-box QA and UX report
4
+ * (adeu 1.25.0+35c8bb4). Mirrors python/tests/test_repro_qa_report_v7.py
5
+ * for the findings that live in the shared core engine:
6
+ *
7
+ * F-02 a formatting-only replacement (bold -> italic) inherits the
8
+ * replaced span's bold instead of realizing exactly the markers
9
+ * F-03 a bold run with boundary whitespace projects as malformed
10
+ * Markdown (`**The Supplier **`) in both ingest and mapper
11
+ * F-04 an alt-text-only image difference becomes an unappliable edit
12
+ * F-06 match_mode=all rebuilds the document map once per occurrence
13
+ * F-09 sanitize leaves original comment timestamps un-normalized
14
+ * F-13 an invalid regex target is reported as "not found" instead of
15
+ * as a regex syntax error
16
+ * F-21 edits_applied counts occurrences instead of change objects
17
+ *
18
+ * Every test fails against the commit preceding its fix.
19
+ */
20
+
21
+ import { describe, it, expect } from "vitest";
22
+ import { readFileSync } from "node:fs";
23
+ import { resolve, dirname } from "node:path";
24
+ import { fileURLToPath } from "node:url";
25
+ import { createTestDocument, addParagraph } from "./test-utils.js";
26
+ import { DocumentObject } from "./docx/bridge.js";
27
+ import { DocumentMapper } from "./mapper.js";
28
+ import { RedlineEngine, BatchValidationError } from "./engine.js";
29
+ import { _extractTextFromDoc, ExtractStructure } from "./ingest.js";
30
+ import {
31
+ generate_structured_edits,
32
+ collect_media_difference_warnings,
33
+ } from "./diff.js";
34
+ import { zipSync } from "fflate";
35
+ import { finalize_document } from "./sanitize/core.js";
36
+ import { qn } from "./docx/dom.js";
37
+
38
+ const __filename = fileURLToPath(import.meta.url);
39
+ const __dirname = dirname(__filename);
40
+
41
+ function addStyledRun(
42
+ p: Element,
43
+ text: string,
44
+ style: { bold?: boolean; italic?: boolean } = {},
45
+ ): Element {
46
+ const xmlDoc = p.ownerDocument!;
47
+ const r = xmlDoc.createElement("w:r");
48
+ if (style.bold || style.italic) {
49
+ const rPr = xmlDoc.createElement("w:rPr");
50
+ if (style.bold) rPr.appendChild(xmlDoc.createElement("w:b"));
51
+ if (style.italic) rPr.appendChild(xmlDoc.createElement("w:i"));
52
+ r.appendChild(rPr);
53
+ }
54
+ const t = xmlDoc.createElement("w:t");
55
+ t.textContent = text;
56
+ if (text !== text.trim()) t.setAttribute("xml:space", "preserve");
57
+ r.appendChild(t);
58
+ p.appendChild(r);
59
+ return r;
60
+ }
61
+
62
+ function addEmptyParagraph(doc: DocumentObject): Element {
63
+ const xmlDoc = doc.element.ownerDocument!;
64
+ const p = xmlDoc.createElement("w:p");
65
+ doc.element.appendChild(p);
66
+ return p;
67
+ }
68
+
69
+ /** Builds the QA report's F-02 fixture: "Normal before **Important phrase** normal after." */
70
+ async function buildBoldDoc(): Promise<DocumentObject> {
71
+ const doc = await createTestDocument();
72
+ const p = addEmptyParagraph(doc);
73
+ addStyledRun(p, "Normal before ");
74
+ addStyledRun(p, "Important phrase", { bold: true });
75
+ addStyledRun(p, " normal after.");
76
+ return doc;
77
+ }
78
+
79
+ function cleanText(doc: DocumentObject): string {
80
+ return _extractTextFromDoc(doc, { cleanView: true, includeAppendix: false }) as string;
81
+ }
82
+
83
+ // ---------------------------------------------------------------------------
84
+ // F-02: formatting-only replacements realize exactly the requested markers
85
+ // ---------------------------------------------------------------------------
86
+
87
+ describe("QA v7 F-02: formatting-only replacement fidelity", () => {
88
+ it("clears inherited bold when the replacement carries explicit italic markers", async () => {
89
+ const doc = await buildBoldDoc();
90
+ const engine = new RedlineEngine(doc);
91
+ engine.process_batch([
92
+ {
93
+ type: "modify",
94
+ target_text: "**Important phrase**",
95
+ new_text: "_Important phrase_",
96
+ },
97
+ ]);
98
+ engine.accept_all_revisions();
99
+
100
+ const accepted = cleanText(doc);
101
+ expect(accepted).toContain("_Important phrase_");
102
+ expect(accepted).not.toContain("**");
103
+ });
104
+
105
+ it("clears bold when the replacement removes the markers entirely", async () => {
106
+ const doc = await buildBoldDoc();
107
+ const engine = new RedlineEngine(doc);
108
+ engine.process_batch([
109
+ {
110
+ type: "modify",
111
+ target_text: "**Important phrase**",
112
+ new_text: "Important phrase",
113
+ },
114
+ ]);
115
+ engine.accept_all_revisions();
116
+
117
+ const accepted = cleanText(doc);
118
+ expect(accepted).toContain("Normal before Important phrase normal after.");
119
+ expect(accepted).not.toContain("**");
120
+ });
121
+
122
+ it("still inherits bold for unmarked plain-text replacements", async () => {
123
+ const doc = await buildBoldDoc();
124
+ const engine = new RedlineEngine(doc);
125
+ engine.process_batch([
126
+ { type: "modify", target_text: "Important", new_text: "Critical" },
127
+ ]);
128
+ engine.accept_all_revisions();
129
+ expect(cleanText(doc)).toContain("**Critical phrase**");
130
+ });
131
+ });
132
+
133
+ // ---------------------------------------------------------------------------
134
+ // F-03: styled-run boundary whitespace stays outside emphasis markers
135
+ // ---------------------------------------------------------------------------
136
+
137
+ describe("QA v7 F-03: styled-run boundary whitespace", () => {
138
+ async function buildBoundaryDoc(): Promise<DocumentObject> {
139
+ const doc = await createTestDocument();
140
+ const p = addEmptyParagraph(doc);
141
+ addStyledRun(p, "The Supplier ", { bold: true });
142
+ addStyledRun(p, "shall deliver");
143
+ addStyledRun(p, " the Services by 31 December 2026.", { italic: true });
144
+ return doc;
145
+ }
146
+
147
+ it("extraction emits valid markdown with whitespace outside the markers", async () => {
148
+ const doc = await buildBoundaryDoc();
149
+ const text = (_extractTextFromDoc(doc, { cleanView: false }) as string).trim();
150
+ expect(text).toBe(
151
+ "**The Supplier** shall deliver _the Services by 31 December 2026._",
152
+ );
153
+ });
154
+
155
+ it("mapper projection matches ingest projection (virtual text contract)", async () => {
156
+ const doc = await buildBoundaryDoc();
157
+ const ingestText = (_extractTextFromDoc(doc, { cleanView: false }) as string).trim();
158
+ const mapper = new DocumentMapper(doc);
159
+ expect(mapper.full_text.trim()).toBe(ingestText);
160
+ });
161
+
162
+ it("plain-text edits across the styled boundary still apply", async () => {
163
+ const doc = await buildBoundaryDoc();
164
+ const engine = new RedlineEngine(doc);
165
+ const stats = engine.process_batch([
166
+ { type: "modify", target_text: "shall deliver", new_text: "must deliver" },
167
+ ]);
168
+ expect(stats.edits_applied).toBe(1);
169
+ engine.accept_all_revisions();
170
+ expect(cleanText(doc)).toContain("must deliver");
171
+ });
172
+
173
+ it("whitespace-only styled runs are not wrapped", async () => {
174
+ const doc = await createTestDocument();
175
+ const p = addEmptyParagraph(doc);
176
+ addStyledRun(p, "Alpha");
177
+ addStyledRun(p, " ", { bold: true });
178
+ addStyledRun(p, "Omega");
179
+ const text = (_extractTextFromDoc(doc, { cleanView: false }) as string).trim();
180
+ expect(text).toBe("Alpha Omega");
181
+ });
182
+ });
183
+
184
+ // ---------------------------------------------------------------------------
185
+ // F-04: an alt-text-only image difference must not become an edit
186
+ // ---------------------------------------------------------------------------
187
+
188
+ describe("QA v7 F-04: image alt-text diffs", () => {
189
+ it("drops hunks that reach inside a read-only image marker, with a warning", () => {
190
+ const text_orig = "Before image.\n\n![RED logo](docx-image:1)\n\nAfter image.";
191
+ const text_mod = "Before image.\n\n![BLUE logo](docx-image:1)\n\nAfter image.";
192
+ const struct = (text: string): ExtractStructure => ({
193
+ part_ranges: [[0, text.length, "body"]],
194
+ tables: [],
195
+ });
196
+
197
+ const { edits, warnings } = generate_structured_edits(
198
+ text_orig,
199
+ struct(text_orig),
200
+ text_mod,
201
+ struct(text_mod),
202
+ );
203
+
204
+ expect(edits).toEqual([]);
205
+ expect(
206
+ warnings.some((w) => /alternative text|image/i.test(w)),
207
+ ).toBe(true);
208
+ });
209
+ });
210
+
211
+ describe("QA v7 F-04: media byte differences", () => {
212
+ const makePkg = (mediaBytes: number[]): Uint8Array =>
213
+ zipSync({
214
+ "[Content_Types].xml": new TextEncoder().encode("<Types/>"),
215
+ "word/document.xml": new TextEncoder().encode("<w:document/>"),
216
+ "word/media/image1.png": new Uint8Array(mediaBytes),
217
+ });
218
+
219
+ it("warns when embedded media bytes differ", () => {
220
+ const warnings = collect_media_difference_warnings(
221
+ makePkg([1, 2, 3]),
222
+ makePkg([4, 5, 6]),
223
+ );
224
+ expect(warnings.length).toBe(1);
225
+ expect(warnings[0]).toMatch(/embedded media differ/);
226
+ });
227
+
228
+ it("stays silent when media are byte-identical", () => {
229
+ const warnings = collect_media_difference_warnings(
230
+ makePkg([1, 2, 3]),
231
+ makePkg([1, 2, 3]),
232
+ );
233
+ expect(warnings).toEqual([]);
234
+ });
235
+ });
236
+
237
+ // ---------------------------------------------------------------------------
238
+ // F-06: match_mode=all must not rebuild the map once per occurrence
239
+ // ---------------------------------------------------------------------------
240
+
241
+ describe("QA v7 F-06: all-match application scaling", () => {
242
+ it("keeps map rebuilds constant as occurrence count grows", async () => {
243
+ const n = 30;
244
+ const doc = await createTestDocument();
245
+ for (let i = 0; i < n; i++) {
246
+ addParagraph(
247
+ doc,
248
+ `Clause ${i}: The party PLACEHOLDER-${String(i).padStart(4, "0")} shall comply. REPLACEME token.`,
249
+ );
250
+ }
251
+ const engine = new RedlineEngine(doc);
252
+
253
+ const proto = DocumentMapper.prototype as any;
254
+ const originalBuild = proto._build_map;
255
+ let buildCount = 0;
256
+ proto._build_map = function (this: any) {
257
+ buildCount += 1;
258
+ return originalBuild.call(this);
259
+ };
260
+
261
+ let stats: any;
262
+ try {
263
+ stats = engine.process_batch([
264
+ {
265
+ type: "modify",
266
+ target_text: "REPLACEME",
267
+ new_text: "SWAPPED",
268
+ regex: true,
269
+ match_mode: "all",
270
+ },
271
+ ]);
272
+ } finally {
273
+ proto._build_map = originalBuild;
274
+ }
275
+
276
+ expect(stats.edits_skipped).toBe(0);
277
+ expect(buildCount).toBeLessThan(n);
278
+
279
+ engine.accept_all_revisions();
280
+ const final = cleanText(doc);
281
+ expect(final.match(/SWAPPED/g)?.length).toBe(n);
282
+ expect(final).not.toContain("REPLACEME");
283
+ });
284
+ });
285
+
286
+ // ---------------------------------------------------------------------------
287
+ // F-09: comment timestamps must be normalized alongside change timestamps
288
+ // ---------------------------------------------------------------------------
289
+
290
+ describe("QA v7 F-09: comment timestamp normalization", () => {
291
+ it("normalizes w:date and w16cex:dateUtc in retained comment parts", async () => {
292
+ const fixturePath = resolve(
293
+ __dirname,
294
+ "../../../../shared/fixtures/golden.docx",
295
+ );
296
+ const doc = await DocumentObject.load(readFileSync(fixturePath));
297
+
298
+ await finalize_document(doc, {
299
+ filename: "golden.docx",
300
+ sanitize_mode: "keep-markup",
301
+ });
302
+
303
+ const commentParts = doc.pkg.parts.filter((p) =>
304
+ p.contentType.includes("comments"),
305
+ );
306
+ expect(commentParts.length).toBeGreaterThan(0);
307
+
308
+ const dates: string[] = [];
309
+ for (const part of commentParts) {
310
+ const xml = part._element.toString();
311
+ for (const m of xml.matchAll(/(?:w:date|w16cex:dateUtc)="([^"]+)"/g)) {
312
+ dates.push(m[1]);
313
+ }
314
+ }
315
+ expect(dates.length).toBeGreaterThan(0);
316
+ for (const d of dates) {
317
+ expect(d.startsWith("2025-01-01")).toBe(true);
318
+ }
319
+ });
320
+ });
321
+
322
+ // ---------------------------------------------------------------------------
323
+ // F-13: invalid regex targets must be diagnosed as regex errors
324
+ // ---------------------------------------------------------------------------
325
+
326
+ describe("QA v7 F-13: invalid regex diagnosis", () => {
327
+ it("names the regex problem instead of 'not found'", async () => {
328
+ const doc = await createTestDocument();
329
+ addParagraph(doc, "alpha DUPTOKEN one.");
330
+ const engine = new RedlineEngine(doc);
331
+
332
+ let error: BatchValidationError | null = null;
333
+ try {
334
+ engine.process_batch([
335
+ { type: "modify", target_text: "[", new_text: "x", regex: true },
336
+ ]);
337
+ } catch (e) {
338
+ error = e as BatchValidationError;
339
+ }
340
+ expect(error).toBeInstanceOf(BatchValidationError);
341
+ const msg = (error as BatchValidationError).errors.join("\n");
342
+ expect(msg).toMatch(/regular expression/i);
343
+ expect(msg).not.toMatch(/not found/i);
344
+ });
345
+
346
+ it("valid regexes still apply", async () => {
347
+ const doc = await createTestDocument();
348
+ addParagraph(doc, "alpha DUPTOKEN one.");
349
+ const engine = new RedlineEngine(doc);
350
+ const stats = engine.process_batch([
351
+ { type: "modify", target_text: "DUP\\w+", new_text: "XTOKEN", regex: true },
352
+ ]);
353
+ expect(stats.edits_applied).toBe(1);
354
+ });
355
+ });
356
+
357
+ // ---------------------------------------------------------------------------
358
+ // F-21: edits_applied counts change objects, not occurrences
359
+ // ---------------------------------------------------------------------------
360
+
361
+ describe("QA v7 F-21: edits_applied semantics", () => {
362
+ it("reports one applied edit with two modified occurrences", async () => {
363
+ const doc = await createTestDocument();
364
+ addParagraph(doc, "alpha DUPTOKEN one.");
365
+ addParagraph(doc, "beta DUPTOKEN two.");
366
+ const engine = new RedlineEngine(doc);
367
+ const stats = engine.process_batch([
368
+ {
369
+ type: "modify",
370
+ target_text: "DUPTOKEN",
371
+ new_text: "NEWTOKEN",
372
+ match_mode: "all",
373
+ },
374
+ ]);
375
+ expect(stats.edits.length).toBe(1);
376
+ expect(stats.edits_applied).toBe(1);
377
+ expect(stats.occurrences_modified).toBe(2);
378
+ expect(stats.edits[0].occurrences_modified).toBe(2);
379
+ });
380
+ });
@@ -78,6 +78,9 @@ export async function finalize_document(doc: DocumentObject, options: FinalizeOp
78
78
  for (const w of transforms.detect_watermarks(doc)) report.warnings.push(w);
79
79
 
80
80
  report.add_transform_lines(transforms.normalize_change_dates(doc));
81
+ // Retained comments carry the same when-did-they-work signal in
82
+ // word/comments.xml as tracked changes do in the body (QA 2026-07-19 F-09).
83
+ report.add_transform_lines(transforms.normalize_comment_dates(doc));
81
84
 
82
85
  // Protection (Settings injection)
83
86
  if (options.protection_mode === 'read_only' || options.protection_mode === 'encrypt') {
@@ -417,6 +417,35 @@ export function normalize_change_dates(doc: DocumentObject): string[] {
417
417
  return count ? [`Track change timestamps: ${count} normalized`] : [];
418
418
  }
419
419
 
420
+ /**
421
+ * Normalize timestamps on RETAINED comments to the fixed sanitize date.
422
+ * Keep-markup sanitize normalized core and tracked-change timestamps but
423
+ * left the original comment timestamps in word/comments.xml (w:date) and
424
+ * word/commentsExtensible.xml (w16cex:dateUtc) — visible through any
425
+ * extraction and carrying exactly the when-did-they-work signal the other
426
+ * normalizations remove (QA 2026-07-19 F-09).
427
+ */
428
+ export function normalize_comment_dates(doc: DocumentObject): string[] {
429
+ let count = 0;
430
+ const fixed = "2025-01-01T00:00:00Z";
431
+ for (const part of doc.pkg.parts) {
432
+ if (!part.contentType.includes('comments')) continue;
433
+ for (const el of findAllDescendants(part._element, 'w:comment')) {
434
+ if (el.hasAttribute('w:date')) {
435
+ el.setAttribute('w:date', fixed);
436
+ count++;
437
+ }
438
+ }
439
+ for (const el of findAllDescendants(part._element, 'w16cex:commentExtensible')) {
440
+ if (el.hasAttribute('w16cex:dateUtc')) {
441
+ el.setAttribute('w16cex:dateUtc', fixed);
442
+ count++;
443
+ }
444
+ }
445
+ }
446
+ return count ? [`Comment timestamps: ${count} normalized`] : [];
447
+ }
448
+
420
449
  export function scrub_doc_properties(doc: DocumentObject): string[] {
421
450
  const lines: string[] = [];
422
451
  const corePart = doc.pkg.getPartByPath('docProps/core.xml');