@ansonlai/docx-redline-js 0.4.0 → 0.5.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.
Files changed (100) hide show
  1. package/AGENTS.md +589 -287
  2. package/ARCHITECTURE.md +215 -9
  3. package/CHANGELOG.md +319 -0
  4. package/README.md +604 -360
  5. package/adapters/config.js +45 -43
  6. package/bin/docx-redline.js +3 -0
  7. package/core/list-targeting.js +101 -110
  8. package/core/paragraph-targeting.js +501 -61
  9. package/core/paragraph-text.js +209 -0
  10. package/core/revision-cloning.js +38 -0
  11. package/core/types.js +64 -10
  12. package/core/word-xml.js +43 -15
  13. package/dist/docx-redline-js.esm.js +2849 -466
  14. package/dist/docx-redline-js.esm.js.map +4 -4
  15. package/dist/docx-redline-js.esm.min.js +87 -76
  16. package/dist/docx-redline-js.esm.min.js.map +4 -4
  17. package/docs/TESTING.md +342 -23
  18. package/docs/plans/2026-09-05-structural-revisions-and-fidelity-oracles.md +1669 -0
  19. package/docs/plans/completed/2026-09-01-performance-and-complexity-reduction.md +669 -0
  20. package/docs/plans/completed/2026-09-03-agent-friendly-document-workflows.md +427 -0
  21. package/docs/plans/completed/2026-09-04-comment-anchor-and-cli-reliability.md +519 -0
  22. package/docs/plans/completed/PERFORMANCE-CONSOLIDATION.md +69 -0
  23. package/docs/plans/completed/structural-revision-capability-matrix.md +115 -0
  24. package/docs/schemas/document-operations.schema.json +109 -0
  25. package/docs/test-comparison-dashboard.html +4250 -7
  26. package/engine/formatting-removal.js +11 -2
  27. package/engine/oxml-engine.js +491 -336
  28. package/engine/reconstruction-mode.js +15 -14
  29. package/engine/reconstruction-writer.js +247 -142
  30. package/engine/route-selection.js +35 -0
  31. package/engine/rpr-helpers.js +334 -35
  32. package/engine/run-builders.js +239 -196
  33. package/engine/surgical-diff-application.js +222 -37
  34. package/engine/surgical-mode.js +134 -6
  35. package/engine/surgical-spans.js +52 -1
  36. package/engine/table-cell-context.js +3 -6
  37. package/engine/table-mode.js +1 -1
  38. package/index.d.ts +234 -6
  39. package/index.js +24 -1
  40. package/node/cli.js +317 -0
  41. package/node/docx-document.js +302 -0
  42. package/node/index.d.ts +31 -0
  43. package/node/index.js +2 -0
  44. package/node/zip-archive.js +52 -0
  45. package/orchestration/list-markdown.js +10 -16
  46. package/orchestration/list-parsing.js +7 -12
  47. package/orchestration/list-structural-fallback.js +21 -10
  48. package/package.json +24 -3
  49. package/pipeline/content-analysis.js +12 -17
  50. package/pipeline/ingestion-export.js +3 -31
  51. package/pipeline/ingestion-paragraph.js +10 -5
  52. package/pipeline/list-generation.js +150 -55
  53. package/pipeline/list-markers.js +70 -3
  54. package/pipeline/serialization.js +4 -2
  55. package/pipeline/structured-content.js +160 -0
  56. package/scripts/apply_changes.mjs +27 -0
  57. package/scripts/benchmark-operation-session.mjs +137 -0
  58. package/scripts/benchmark-targeting-browser.html +74 -0
  59. package/scripts/benchmark-targeting-hot-paths.mjs +67 -0
  60. package/scripts/benchmark-test-runner.mjs +59 -0
  61. package/scripts/build-test-dashboard.mjs +23 -0
  62. package/scripts/export-lane1-fixtures.mjs +380 -0
  63. package/scripts/export-reredline-stress-fixtures.mjs +317 -0
  64. package/scripts/export-validation-fixtures.mjs +1 -1
  65. package/scripts/extract_text.mjs +7 -0
  66. package/scripts/generate-paragraph-boundary-fixtures.ps1 +215 -0
  67. package/scripts/generate-test-dashboard.mjs +362 -11
  68. package/scripts/lib/word-coverage-catalogue.mjs +6 -2
  69. package/scripts/profile-route-selection.mjs +19 -0
  70. package/scripts/render-agenda-multilevel.mjs +0 -5
  71. package/scripts/render-multilevel-cases.mjs +0 -1
  72. package/scripts/run-tests.mjs +107 -35
  73. package/scripts/word-com-corpus-suite.ps1 +3 -0
  74. package/scripts/word-com-differential.ps1 +64 -4
  75. package/scripts/word-com-suite.ps1 +3 -0
  76. package/services/batch-operation-orchestrator.js +494 -0
  77. package/services/capture-engine.js +226 -0
  78. package/services/comment-builders.js +23 -6
  79. package/services/comment-engine.js +108 -47
  80. package/services/comment-locator.js +187 -82
  81. package/services/comment-replies.js +95 -0
  82. package/services/document-inspection.js +258 -0
  83. package/services/document-operation-applier.js +372 -0
  84. package/services/document-operation-contract.js +323 -0
  85. package/services/document-operation-mutations.js +1733 -0
  86. package/services/document-operation-session.js +258 -0
  87. package/services/numbering-service.js +14 -5
  88. package/services/operation-heuristics.js +173 -0
  89. package/services/operation-preflight.js +366 -0
  90. package/services/receipt-collector.js +288 -0
  91. package/services/revision-comment-management.js +37 -5
  92. package/services/revision-token.js +290 -0
  93. package/services/standalone-docx-plumbing.js +123 -8
  94. package/services/standalone-operation-runner.d.ts +296 -0
  95. package/services/standalone-operation-runner.js +10 -1455
  96. package/services/table-reconciliation.js +15 -6
  97. package/docs/VALIDATION.md +0 -183
  98. package/docs/WORD-MANUAL-REVIEW.md +0 -138
  99. package/docs/plans/2026-09-01-performance-and-complexity-reduction.md +0 -210
  100. /package/docs/plans/{2026-08-30-reliability-testing-improvements.md → completed/2026-08-30-reliability-testing-improvements.md} +0 -0
@@ -0,0 +1,366 @@
1
+ /**
2
+ * Read-only preflight for document operation batches.
3
+ */
4
+
5
+ import { parseOoxmlSafe } from '../adapters/xml-adapter.js';
6
+ import { getDefaultAuthor } from '../adapters/config.js';
7
+ import { containsTrackedChanges, getTrackedChangeAuthors } from '../core/word-xml.js';
8
+ import { NS_W } from '../core/types.js';
9
+ import { extractCanonicalParagraphText } from '../core/paragraph-text.js';
10
+ import {
11
+ buildParagraphMetadataIndex,
12
+ createParagraphFingerprint,
13
+ findContainingWordElement,
14
+ getDocumentParagraphNodes,
15
+ getParagraphId,
16
+ normalizeWhitespaceForTargeting,
17
+ resolveTargetParagraph,
18
+ validateParagraphBoundaryMutation
19
+ } from '../core/paragraph-targeting.js';
20
+ import { createParagraphTextIndex, resolveTextInParagraphIndex } from './comment-locator.js';
21
+ import {
22
+ normalizeDocumentOperation,
23
+ resolveDocumentOperationAuthor,
24
+ validateDocumentOperation
25
+ } from './document-operation-contract.js';
26
+ import { buildOperationDependencyPlan } from './batch-operation-orchestrator.js';
27
+
28
+ function normalizedError(error) {
29
+ return {
30
+ code: typeof error?.code === 'string' && error.code ? error.code : 'OPERATION_ERROR',
31
+ message: error?.message || String(error),
32
+ ...(Array.isArray(error?.candidates) ? { candidates: error.candidates } : {})
33
+ };
34
+ }
35
+
36
+ function operationNeedsNumbering(operation) {
37
+ if (operation.operationKind !== 'redline' || typeof operation.modified !== 'string') return false;
38
+ return operation.modified.split(/\r?\n/).some(line => /^\s*(?:[-+*]|\d+[.)])\s+/.test(line));
39
+ }
40
+
41
+ function targetMetadata(xmlDoc, paragraph, resolvedBy, suppliedText, paragraphMetadataIndex = null, revisionView = 'accepted') {
42
+ const cached = paragraphMetadataIndex?.byParagraph?.get(paragraph) || null;
43
+ const paragraphs = cached ? null : getDocumentParagraphNodes(xmlDoc);
44
+ const actualText = cached?.text ?? extractCanonicalParagraphText(paragraph, { revisionView });
45
+ const normalizedSupplied = normalizeWhitespaceForTargeting(suppliedText || '');
46
+ const normalizedActual = normalizeWhitespaceForTargeting(actualText);
47
+ return {
48
+ resolvedBy,
49
+ resolvedTarget: {
50
+ index: cached?.index ?? paragraphs.indexOf(paragraph) + 1,
51
+ paragraphId: cached?.paragraphId ?? getParagraphId(paragraph),
52
+ text: actualText,
53
+ fingerprint: cached?.fingerprint ?? createParagraphFingerprint(paragraph, { text: actualText, revisionView }),
54
+ inTable: cached?.inTable ?? !!findContainingWordElement(paragraph, 'tbl'),
55
+ revisionView
56
+ },
57
+ matchDiagnostics: {
58
+ exactTextMatch: typeof suppliedText === 'string' && suppliedText === actualText,
59
+ normalizedTextMatch: !!normalizedSupplied && normalizedSupplied === normalizedActual,
60
+ suppliedText: suppliedText || '',
61
+ actualText,
62
+ revisionView
63
+ }
64
+ };
65
+ }
66
+
67
+ function buildConflict(code, message, operationIndexes, target) {
68
+ return { code, message, operationIndexes, target };
69
+ }
70
+
71
+ function getCommentIdsInParagraph(paragraph) {
72
+ const ids = new Set();
73
+ for (const localName of ['commentRangeStart', 'commentRangeEnd', 'commentReference']) {
74
+ for (const node of Array.from(paragraph?.getElementsByTagNameNS?.('*', localName) || [])) {
75
+ const id = node.getAttribute('w:id') || node.getAttribute('id');
76
+ if (id !== '') ids.add(id);
77
+ }
78
+ }
79
+ return Array.from(ids).sort((a, b) => Number(a) - Number(b) || a.localeCompare(b));
80
+ }
81
+
82
+ export function preflightOperations(documentXml, operations, author, options = {}) {
83
+ const parsed = parseOoxmlSafe(documentXml, 'application/xml');
84
+ if (parsed.error || !parsed.doc) {
85
+ return {
86
+ valid: false,
87
+ status: 'error',
88
+ error: parsed.error,
89
+ results: [],
90
+ conflicts: [],
91
+ authorsUsed: [],
92
+ requiredArtifacts: { comments: false, numbering: false }
93
+ };
94
+ }
95
+
96
+ const xmlDoc = parsed.doc;
97
+ const metadataIndices = {
98
+ accepted: buildParagraphMetadataIndex(xmlDoc, { revisionView: 'accepted' }),
99
+ rejected: null
100
+ };
101
+ const sourceOperations = Array.isArray(operations) ? operations : [];
102
+ const dependencyPlan = buildOperationDependencyPlan(sourceOperations);
103
+ if (!dependencyPlan.valid) {
104
+ return {
105
+ valid: false,
106
+ status: 'error',
107
+ error: dependencyPlan.error,
108
+ results: [],
109
+ conflicts: [],
110
+ authorsUsed: [],
111
+ requiredArtifacts: { comments: false, numbering: false }
112
+ };
113
+ }
114
+
115
+ const strictTargets = options.strictTargets !== false;
116
+ const results = [];
117
+ const authorsUsed = new Set();
118
+ let commentsRequired = false;
119
+ let numberingRequired = false;
120
+
121
+ for (let index = 0; index < sourceOperations.length; index++) {
122
+ const sourceOperation = sourceOperations[index];
123
+ const validation = validateDocumentOperation(sourceOperation);
124
+ const fallbackOperation = normalizeDocumentOperation(sourceOperation);
125
+ const operation = validation.operation || fallbackOperation;
126
+ const authorUsed = resolveDocumentOperationAuthor(operation, author, getDefaultAuthor());
127
+ authorsUsed.add(authorUsed);
128
+
129
+ if (!validation.valid) {
130
+ results.push({
131
+ index: index + 1,
132
+ type: sourceOperation?.type || 'redline',
133
+ operationType: operation.operationKind,
134
+ status: 'error',
135
+ authorUsed,
136
+ error: validation.error
137
+ });
138
+ continue;
139
+ }
140
+
141
+ commentsRequired = commentsRequired || operation.operationKind === 'comment' || operation.operationKind === 'comment_reply';
142
+ numberingRequired = numberingRequired || operationNeedsNumbering(operation);
143
+
144
+ if (operation.operationKind === 'comment_reply') {
145
+ const parentId = String(operation.parentCommentId);
146
+ const parent = options._existingCommentDetails?.[parentId];
147
+ results.push(parent ? {
148
+ index: index + 1, type: sourceOperation.type, operationType: operation.operationKind,
149
+ status: 'ready', authorUsed, resolvedBy: 'parent_comment', parentCommentId: parentId
150
+ } : {
151
+ index: index + 1, type: sourceOperation.type, operationType: operation.operationKind,
152
+ status: 'error', authorUsed,
153
+ error: { code: 'PARENT_COMMENT_NOT_FOUND', message: `Parent comment '${parentId}' was not found.` }
154
+ });
155
+ continue;
156
+ }
157
+
158
+ if (operation.targetDescriptor?.captureRef) {
159
+ results.push({
160
+ index: index + 1,
161
+ type: sourceOperation?.type || 'redline',
162
+ operationType: operation.operationKind,
163
+ status: 'deferred',
164
+ authorUsed,
165
+ resolvedBy: 'capture',
166
+ captureRef: operation.targetDescriptor.captureRef,
167
+ ...(operation.targetDescriptor.select ? { select: operation.targetDescriptor.select } : {})
168
+ });
169
+ continue;
170
+ }
171
+
172
+ const targetView = operation.targetDescriptor?.revisionView === 'rejected' ? 'rejected' : 'accepted';
173
+ let currentMetadataIndex = targetView === 'rejected'
174
+ ? (metadataIndices.rejected || (metadataIndices.rejected = buildParagraphMetadataIndex(xmlDoc, { revisionView: 'rejected' })))
175
+ : metadataIndices.accepted;
176
+
177
+ try {
178
+ const resolved = resolveTargetParagraph(xmlDoc, {
179
+ targetText: operation.target,
180
+ targetRef: operation.targetRef,
181
+ targetDescriptor: operation.targetDescriptor,
182
+ opType: operation.operationKind,
183
+ strictAmbiguity: strictTargets,
184
+ paragraphMetadataIndex: currentMetadataIndex,
185
+ metadataIndices,
186
+ onInfo: options.onInfo,
187
+ onWarn: options.onWarn
188
+ });
189
+ const paragraph = resolved.paragraph;
190
+ const metadata = targetMetadata(xmlDoc, paragraph, resolved.resolvedBy, operation.target, currentMetadataIndex, targetView);
191
+ const paragraphText = metadata.resolvedTarget.text;
192
+ const anchor = operation.operationKind === 'comment'
193
+ ? (operation.textToComment || paragraphText)
194
+ : (operation.operationKind === 'highlight'
195
+ ? operation.textToHighlight
196
+ : (operation.operationKind === 'format' ? operation.textToFormat : null));
197
+ const anchorResolution = operation.operationKind === 'comment' && anchor != null
198
+ ? resolveTextInParagraphIndex(createParagraphTextIndex(paragraph, { revisionView: targetView }), anchor)
199
+ : null;
200
+ const anchorFound = anchor == null
201
+ || (anchorResolution ? anchorResolution.found : paragraphText.includes(anchor));
202
+ const hasRevisions = containsTrackedChanges(paragraph);
203
+ const existingPolicy = operation.existingRevisions
204
+ || options.existingRevisions
205
+ || 'merge-same-author';
206
+ const deletingWholeParagraph = operation.operationKind === 'redline' && operation.modified === '';
207
+ const commentIds = deletingWholeParagraph ? getCommentIdsInParagraph(paragraph) : [];
208
+
209
+ let error = null;
210
+ if (!anchorFound) {
211
+ error = anchorResolution?.error || {
212
+ code: 'ANCHOR_NOT_FOUND',
213
+ message: `Anchor text was not found in target paragraph: "${anchor}".`
214
+ };
215
+ } else if (commentIds.length > 0) {
216
+ const comments = commentIds
217
+ .map(id => options._existingCommentDetails?.[id])
218
+ .filter(Boolean);
219
+ error = {
220
+ code: 'COMMENTED_CONTENT_DELETE',
221
+ message: 'Refusing to delete a paragraph with existing comments. Resolve or explicitly remove the comments before deleting the paragraph.',
222
+ commentIds,
223
+ ...(comments.length > 0 ? { comments } : {})
224
+ };
225
+ } else if (
226
+ operation.operationKind === 'redline'
227
+ && hasRevisions
228
+ && existingPolicy !== 'accept-all-first'
229
+ && existingPolicy !== 'accept-all-first-keep-normalized'
230
+ ) {
231
+ if (existingPolicy === 'merge-same-author') {
232
+ const authors = getTrackedChangeAuthors(paragraph);
233
+ const opAuthor = String(authorUsed || '').trim().toLowerCase();
234
+ const allSame = authors.length > 0 && authors.every(a => a.trim().toLowerCase() === opAuthor);
235
+ if (!allSame) {
236
+ error = {
237
+ code: 'EXISTING_REVISIONS',
238
+ message: `Target paragraph contains tracked changes from another author (${authors.length ? authors.join(', ') : 'unattributed'}). Pass existingRevisions: "accept-all-first" or resolve revisions first.`
239
+ };
240
+ } else {
241
+ const mergeCommentIds = getCommentIdsInParagraph(paragraph);
242
+ if (mergeCommentIds.length > 0) {
243
+ const comments = mergeCommentIds
244
+ .map(id => options._existingCommentDetails?.[id])
245
+ .filter(Boolean);
246
+ error = {
247
+ code: 'COMMENTED_CONTENT_MERGE',
248
+ message: 'Refusing to merge existing revisions in commented content because reverting the prior revisions could remove or orphan comment anchors.',
249
+ commentIds: mergeCommentIds,
250
+ ...(comments.length > 0 ? { comments } : {})
251
+ };
252
+ }
253
+ }
254
+ } else {
255
+ const hasDel = paragraph.getElementsByTagNameNS(NS_W, 'del').length > 0;
256
+ const hasMove = paragraph.getElementsByTagNameNS(NS_W, 'moveFrom').length > 0
257
+ || paragraph.getElementsByTagNameNS(NS_W, 'moveTo').length > 0;
258
+ if (hasDel) {
259
+ error = {
260
+ code: 'UNSAFE_REVISION_NESTING',
261
+ message: 'Refusing to replace content with pending deletions; nesting revisions is unsafe.'
262
+ };
263
+ } else if (hasMove) {
264
+ error = {
265
+ code: 'UNSAFE_REVISION_NESTING',
266
+ message: 'Refusing to mutate content with move revisions until move lifecycle is designed.'
267
+ };
268
+ } else {
269
+ error = {
270
+ code: 'EXISTING_REVISIONS',
271
+ message: 'Target paragraph contains tracked changes and existingRevisions is "reject-input".'
272
+ };
273
+ }
274
+ }
275
+ }
276
+
277
+ if (!error && operation.operationKind === 'redline') {
278
+ const boundaryCheck = validateParagraphBoundaryMutation(paragraph, operation.modified, options);
279
+ if (!boundaryCheck.valid) {
280
+ error = {
281
+ code: boundaryCheck.code,
282
+ message: boundaryCheck.message
283
+ };
284
+ }
285
+ }
286
+
287
+ results.push({
288
+ index: index + 1,
289
+ type: sourceOperation?.type || 'redline',
290
+ operationType: operation.operationKind,
291
+ status: error ? 'error' : 'ready',
292
+ authorUsed,
293
+ ...metadata,
294
+ anchor: anchor == null ? null : {
295
+ text: anchor,
296
+ found: anchorFound,
297
+ ...(anchorResolution?.found ? {
298
+ resolvedBy: anchorResolution.resolvedBy,
299
+ start: anchorResolution.start,
300
+ end: anchorResolution.end
301
+ } : {}),
302
+ ...(!anchorResolution?.found && Array.isArray(anchorResolution?.error?.candidates)
303
+ ? { candidates: anchorResolution.error.candidates }
304
+ : {})
305
+ },
306
+ hasRevisions,
307
+ existingRevisions: existingPolicy,
308
+ ...(resolved?.warnings?.length ? { warnings: resolved.warnings } : {}),
309
+ ...(error ? { error } : {})
310
+ });
311
+ } catch (error) {
312
+ results.push({
313
+ index: index + 1,
314
+ type: sourceOperation?.type || 'redline',
315
+ operationType: operation.operationKind,
316
+ status: 'error',
317
+ authorUsed,
318
+ error: normalizedError(error)
319
+ });
320
+ }
321
+ }
322
+
323
+ const conflicts = [];
324
+ const byTarget = new Map();
325
+ for (const result of results) {
326
+ const targetIndex = result.resolvedTarget?.index;
327
+ if (!targetIndex) continue;
328
+ if (!byTarget.has(targetIndex)) byTarget.set(targetIndex, []);
329
+ byTarget.get(targetIndex).push(result);
330
+ }
331
+
332
+ for (const [targetIndex, targetResults] of byTarget) {
333
+ const redlines = targetResults.filter(result => result.operationType === 'redline');
334
+ const highlights = targetResults.filter(result => result.operationType === 'highlight');
335
+ const target = targetResults[0].resolvedTarget;
336
+ if (redlines.length > 1) {
337
+ conflicts.push(buildConflict(
338
+ 'OVERLAPPING_TEXT_EDITS',
339
+ `Multiple text edits target paragraph ${targetIndex}; later operations may use a stale anchor.`,
340
+ redlines.map(result => result.index),
341
+ target
342
+ ));
343
+ }
344
+ if (redlines.length > 0 && highlights.length > 0) {
345
+ conflicts.push(buildConflict(
346
+ 'REVISION_ORDER_CONFLICT',
347
+ `A text edit and highlight target paragraph ${targetIndex}; operation order can invalidate the target or existing-revision policy.`,
348
+ [...redlines, ...highlights].map(result => result.index).sort((a, b) => a - b),
349
+ target
350
+ ));
351
+ }
352
+ }
353
+
354
+ const hasErrors = results.some(result => result.status === 'error');
355
+ return {
356
+ valid: !hasErrors && conflicts.length === 0,
357
+ status: !hasErrors && conflicts.length === 0 ? 'ok' : 'error',
358
+ results,
359
+ conflicts,
360
+ authorsUsed: Array.from(authorsUsed),
361
+ requiredArtifacts: {
362
+ comments: commentsRequired,
363
+ numbering: numberingRequired
364
+ }
365
+ };
366
+ }
@@ -0,0 +1,288 @@
1
+ /**
2
+ * Internal mutation receipt collector for tracking allocated revision,
3
+ * comment, numbering, and relationship IDs per operation.
4
+ */
5
+
6
+ export class ReceiptCollector {
7
+ constructor() {
8
+ this.receipts = [];
9
+ this.activeReceipt = null;
10
+ }
11
+
12
+ beginOperation(operationIndex, operationId = null, authorUsed = null) {
13
+ this.activeReceipt = {
14
+ operationIndex: typeof operationIndex === 'number' ? operationIndex : 1,
15
+ ...(operationId ? { operationId: String(operationId) } : {}),
16
+ attemptedDisposition: 'applied',
17
+ finalDisposition: 'applied',
18
+ committed: true,
19
+ ...(authorUsed ? { authorUsed: String(authorUsed) } : {}),
20
+ revisionItems: [],
21
+ commentIds: [],
22
+ numberingIds: [],
23
+ relationshipIds: [],
24
+ affectedTargets: [],
25
+ warnings: []
26
+ };
27
+ }
28
+
29
+ recordRevision(id, kind = 'structural', partName = 'word/document.xml') {
30
+ if (!this.activeReceipt || id == null) return;
31
+ const strId = String(id);
32
+ if (!this.activeReceipt.revisionItems.some(item => item.id === strId && item.kind === kind && item.partName === partName)) {
33
+ this.activeReceipt.revisionItems.push({
34
+ id: strId,
35
+ kind,
36
+ partName
37
+ });
38
+ }
39
+ }
40
+
41
+ recordComment(id, _partName = 'word/comments.xml') {
42
+ if (!this.activeReceipt || id == null) return;
43
+ const strId = String(id);
44
+ if (!this.activeReceipt.commentIds.includes(strId)) {
45
+ this.activeReceipt.commentIds.push(strId);
46
+ }
47
+ }
48
+
49
+ recordNumbering(id, _partName = 'word/numbering.xml') {
50
+ if (!this.activeReceipt || id == null) return;
51
+ const strId = String(id);
52
+ if (!this.activeReceipt.numberingIds.includes(strId)) {
53
+ this.activeReceipt.numberingIds.push(strId);
54
+ }
55
+ }
56
+
57
+ recordRelationship(id, _partName = 'word/_rels/document.xml.rels') {
58
+ if (!this.activeReceipt || id == null) return;
59
+ const strId = String(id);
60
+ if (!this.activeReceipt.relationshipIds.includes(strId)) {
61
+ this.activeReceipt.relationshipIds.push(strId);
62
+ }
63
+ }
64
+
65
+ recordAffectedTarget(target) {
66
+ if (!this.activeReceipt || !target) return;
67
+ this.activeReceipt.affectedTargets.push(JSON.parse(JSON.stringify(target)));
68
+ }
69
+
70
+ recordWarning(warning) {
71
+ if (!this.activeReceipt || !warning) return;
72
+ this.activeReceipt.warnings.push(String(warning));
73
+ }
74
+
75
+ commitOperation(disposition = 'applied') {
76
+ if (!this.activeReceipt) return null;
77
+ this.activeReceipt.attemptedDisposition = disposition;
78
+ this.activeReceipt.finalDisposition = disposition;
79
+ this.activeReceipt.committed = disposition === 'applied';
80
+ const committed = JSON.parse(JSON.stringify(this.activeReceipt));
81
+ this.receipts.push(committed);
82
+ this.activeReceipt = null;
83
+ return committed;
84
+ }
85
+
86
+ abortOperation(disposition = 'refused') {
87
+ if (!this.activeReceipt) return null;
88
+ this.activeReceipt.attemptedDisposition = disposition;
89
+ this.activeReceipt.finalDisposition = disposition;
90
+ this.activeReceipt.committed = false;
91
+ const aborted = JSON.parse(JSON.stringify(this.activeReceipt));
92
+ this.activeReceipt = null;
93
+ return aborted;
94
+ }
95
+
96
+ createSavepoint() {
97
+ return {
98
+ receipts: JSON.parse(JSON.stringify(this.receipts)),
99
+ activeReceipt: this.activeReceipt ? JSON.parse(JSON.stringify(this.activeReceipt)) : null
100
+ };
101
+ }
102
+
103
+ restoreSavepoint(savepoint) {
104
+ if (!savepoint) return;
105
+ this.receipts = Array.isArray(savepoint.receipts)
106
+ ? JSON.parse(JSON.stringify(savepoint.receipts))
107
+ : [];
108
+ this.activeReceipt = savepoint.activeReceipt
109
+ ? JSON.parse(JSON.stringify(savepoint.activeReceipt))
110
+ : null;
111
+ }
112
+
113
+ clear() {
114
+ this.receipts = [];
115
+ this.activeReceipt = null;
116
+ }
117
+
118
+ markRolledBack() {
119
+ for (const receipt of this.receipts) {
120
+ if (receipt.attemptedDisposition === 'applied') {
121
+ receipt.finalDisposition = 'rolled_back';
122
+ receipt.committed = false;
123
+ }
124
+ }
125
+ this.activeReceipt = null;
126
+ }
127
+
128
+ getReceipts() {
129
+ return JSON.parse(JSON.stringify(this.receipts));
130
+ }
131
+
132
+ getCurrentReceipt() {
133
+ return this.activeReceipt ? JSON.parse(JSON.stringify(this.activeReceipt)) : null;
134
+ }
135
+ }
136
+
137
+ /**
138
+ * Creates an empty receipt structure for unattempted, refused, or no-op operations.
139
+ *
140
+ * @param {number} operationIndex
141
+ * @param {string|null} [operationId]
142
+ * @param {string|null} [authorUsed]
143
+ * @param {'applied'|'no_change'|'refused'|'not_attempted'} [disposition]
144
+ * @returns {Object}
145
+ */
146
+ export function createEmptyReceipt(operationIndex, operationId = null, authorUsed = null, disposition = 'not_attempted') {
147
+ return {
148
+ operationIndex: typeof operationIndex === 'number' ? operationIndex : 1,
149
+ ...(operationId ? { operationId: String(operationId) } : {}),
150
+ attemptedDisposition: disposition,
151
+ finalDisposition: disposition,
152
+ committed: false,
153
+ ...(authorUsed ? { authorUsed: String(authorUsed) } : {}),
154
+ revisionItems: [],
155
+ commentIds: [],
156
+ numberingIds: [],
157
+ relationshipIds: [],
158
+ affectedTargets: [],
159
+ warnings: []
160
+ };
161
+ }
162
+
163
+ /**
164
+ * Reconciles committed mutation receipts against the generated/committed output parts.
165
+ *
166
+ * @param {Object} parts
167
+ * @param {string} parts.documentXml - Serialized word/document.xml
168
+ * @param {string|null} [parts.commentsXml] - Serialized word/comments.xml
169
+ * @param {string|null} [parts.numberingXml] - Serialized word/numbering.xml
170
+ * @param {string[]} [parts.numberingXmlParts] - Additional numbering XML parts
171
+ * @param {string|null} [parts.relationshipsXml] - Serialized word/_rels/document.xml.rels
172
+ * @param {Array<Object>} receipts - Array of MutationReceipt objects
173
+ * @returns {{ valid: boolean, error?: { code: string, message: string } }}
174
+ */
175
+ export function reconcileReceiptsAgainstOutput(parts, receipts) {
176
+ if (!Array.isArray(receipts) || receipts.length === 0) {
177
+ return { valid: true };
178
+ }
179
+
180
+ const committedReceipts = receipts.filter(r => r && r.committed === true && r.finalDisposition === 'applied');
181
+ if (committedReceipts.length === 0) {
182
+ return { valid: true };
183
+ }
184
+
185
+ const revisionIdSet = new Set();
186
+ if (parts?.documentXml && typeof parts.documentXml === 'string') {
187
+ const revRegex = /<(?:w:)?(?:ins|del|rPrChange|pPrChange|moveFrom|moveTo)\b[^>]*?\b(?:w:)?id="([^"]+)"/g;
188
+ let m;
189
+ while ((m = revRegex.exec(parts.documentXml)) !== null) {
190
+ revisionIdSet.add(m[1]);
191
+ }
192
+ }
193
+
194
+ const commentIdSet = new Set();
195
+ if (parts?.commentsXml && typeof parts.commentsXml === 'string') {
196
+ const comRegex = /<(?:w:)?comment\b[^>]*?\b(?:w:)?id="([^"]+)"/g;
197
+ let m;
198
+ while ((m = comRegex.exec(parts.commentsXml)) !== null) {
199
+ commentIdSet.add(m[1]);
200
+ }
201
+ }
202
+
203
+ const numberingIdSet = new Set();
204
+ const combinedNumbering = [parts?.numberingXml, ...(parts?.numberingXmlParts || [])].filter(Boolean).join('\n');
205
+ if (combinedNumbering) {
206
+ const numRegex = /<(?:w:)?num\b[^>]*?\b(?:w:)?numId="([^"]+)"/g;
207
+ let m;
208
+ while ((m = numRegex.exec(combinedNumbering)) !== null) {
209
+ numberingIdSet.add(m[1]);
210
+ }
211
+ }
212
+ if (parts?.documentXml && typeof parts.documentXml === 'string') {
213
+ const docNumRegex = /<(?:w:)?numId\b[^>]*?\b(?:w:)?val="([^"]+)"/g;
214
+ let m;
215
+ while ((m = docNumRegex.exec(parts.documentXml)) !== null) {
216
+ numberingIdSet.add(m[1]);
217
+ }
218
+ }
219
+
220
+ const relIdSet = new Set();
221
+ if (parts?.relationshipsXml && typeof parts.relationshipsXml === 'string') {
222
+ const relRegex = /<Relationship\b[^>]*?\bId="([^"]+)"/g;
223
+ let m;
224
+ while ((m = relRegex.exec(parts.relationshipsXml)) !== null) {
225
+ relIdSet.add(m[1]);
226
+ }
227
+ }
228
+
229
+ for (const receipt of committedReceipts) {
230
+ if (Array.isArray(receipt.revisionItems)) {
231
+ for (const item of receipt.revisionItems) {
232
+ if (item.partName === 'word/document.xml' && !revisionIdSet.has(String(item.id))) {
233
+ return {
234
+ valid: false,
235
+ error: {
236
+ code: 'RECEIPT_RECONCILIATION_FAILED',
237
+ message: `Committed revision id '${item.id}' (kind: ${item.kind}) was not found in word/document.xml.`
238
+ }
239
+ };
240
+ }
241
+ }
242
+ }
243
+
244
+ if (Array.isArray(receipt.commentIds)) {
245
+ for (const id of receipt.commentIds) {
246
+ if (!commentIdSet.has(String(id))) {
247
+ return {
248
+ valid: false,
249
+ error: {
250
+ code: 'RECEIPT_RECONCILIATION_FAILED',
251
+ message: `Committed comment id '${id}' was not found in word/comments.xml.`
252
+ }
253
+ };
254
+ }
255
+ }
256
+ }
257
+
258
+ if (Array.isArray(receipt.numberingIds)) {
259
+ for (const id of receipt.numberingIds) {
260
+ if (!numberingIdSet.has(String(id))) {
261
+ return {
262
+ valid: false,
263
+ error: {
264
+ code: 'RECEIPT_RECONCILIATION_FAILED',
265
+ message: `Committed numbering id '${id}' was not found in numbering parts or document.`
266
+ }
267
+ };
268
+ }
269
+ }
270
+ }
271
+
272
+ if (parts?.relationshipsXml && Array.isArray(receipt.relationshipIds)) {
273
+ for (const id of receipt.relationshipIds) {
274
+ if (!relIdSet.has(String(id))) {
275
+ return {
276
+ valid: false,
277
+ error: {
278
+ code: 'RECEIPT_RECONCILIATION_FAILED',
279
+ message: `Committed relationship id '${id}' was not found in document.xml.rels.`
280
+ }
281
+ };
282
+ }
283
+ }
284
+ }
285
+ }
286
+
287
+ return { valid: true };
288
+ }