@ansonlai/docx-redline-js 0.5.3 → 0.6.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 (59) hide show
  1. package/AGENTS.md +82 -667
  2. package/ARCHITECTURE.md +51 -4
  3. package/CHANGELOG.md +11 -0
  4. package/README.md +176 -39
  5. package/core/paragraph-revision-safety.js +10 -8
  6. package/core/paragraph-targeting.js +14 -2
  7. package/core/redline-validation.js +7 -4
  8. package/core/revision-cloning.js +21 -0
  9. package/core/validation-delta.js +23 -0
  10. package/dist/docx-redline-js.esm.js +275 -45
  11. package/dist/docx-redline-js.esm.js.map +3 -3
  12. package/dist/docx-redline-js.esm.min.js +82 -82
  13. package/dist/docx-redline-js.esm.min.js.map +4 -4
  14. package/docs/AGENT_FAST_START.md +59 -0
  15. package/docs/AGENT_KNOWLEDGE_BASE.md +868 -0
  16. package/docs/TESTING.md +20 -1
  17. package/docs/schemas/document-operations.schema.json +16 -2
  18. package/docs/validation-reports/2026-09-12-agent-protocol-rollout.md +82 -0
  19. package/engine/oxml-engine.js +80 -13
  20. package/engine/run-builders.js +5 -15
  21. package/engine/surgical-mode.js +148 -3
  22. package/engine/surgical-run-splitting.js +19 -7
  23. package/engine/surgical-spans.js +2 -1
  24. package/index.d.ts +17 -1
  25. package/node/cli.js +235 -36
  26. package/node/docx-document.js +137 -83
  27. package/node/index.d.ts +6 -2
  28. package/package.json +10 -3
  29. package/pipeline/diff-engine.js +15 -0
  30. package/scripts/generate-cross-author-slicing-fixtures.ps1 +25 -25
  31. package/services/batch-operation-orchestrator.js +215 -120
  32. package/services/document-inspection.js +5 -3
  33. package/services/document-operation-applier.js +99 -36
  34. package/services/document-operation-contract.js +50 -6
  35. package/services/document-operation-mutations.js +404 -41
  36. package/services/document-operation-session.js +4 -0
  37. package/services/error-recovery.js +174 -0
  38. package/services/operation-batch-compiler.js +394 -0
  39. package/services/operation-preflight.js +91 -72
  40. package/services/standalone-operation-runner.d.ts +35 -1
  41. package/docs/plans/2026-09-05-structural-revisions-and-fidelity-oracles.md +0 -1669
  42. package/docs/plans/2026-09-08-cross-author-revision-slicing.md +0 -856
  43. package/docs/plans/completed/2026-03-01-release-0.1.4-design.md +0 -33
  44. package/docs/plans/completed/2026-03-01-release-0.1.4.md +0 -110
  45. package/docs/plans/completed/2026-05-31-architectural changes.md +0 -593
  46. package/docs/plans/completed/2026-08-02-reliability-improvements.md +0 -1155
  47. package/docs/plans/completed/2026-08-30-reliability-testing-improvements.md +0 -488
  48. package/docs/plans/completed/2026-09-01-performance-and-complexity-reduction.md +0 -669
  49. package/docs/plans/completed/2026-09-03-agent-friendly-document-workflows.md +0 -427
  50. package/docs/plans/completed/2026-09-04-comment-anchor-and-cli-reliability.md +0 -519
  51. package/docs/plans/completed/PERFORMANCE-CONSOLIDATION.md +0 -69
  52. package/docs/plans/completed/structural-revision-capability-matrix.md +0 -115
  53. package/docs/test-comparison-dashboard.html +0 -4338
  54. package/docs/validation-reports/2026-08-30-phase-1-word-visual-preflight.md +0 -22
  55. package/docs/validation-reports/2026-08-30-phase-2-word-visual-preflight.md +0 -24
  56. package/docs/validation-reports/2026-08-30-phase-3-coverage.md +0 -73
  57. package/docs/validation-reports/2026-09-02-multilevel-bullets-visual-review.md +0 -82
  58. package/docs/validation-reports/2026-09-02-multimodal-visual-samples.md +0 -114
  59. package/docs/validation-reports/2026-09-02-visual-failures-preflight.md +0 -79
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ansonlai/docx-redline-js",
3
- "version": "0.5.3",
3
+ "version": "0.6.0",
4
4
  "description": "Host-independent OOXML reconciliation engine for .docx manipulation with track changes",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -44,7 +44,13 @@
44
44
  "bin/",
45
45
  "orchestration/",
46
46
  "scripts/",
47
- "docs/",
47
+ "!scripts/benchmark-agent-workflow.mjs",
48
+ "!scripts/lib/agent-performance-cases.mjs",
49
+ "docs/AGENT_FAST_START.md",
50
+ "docs/AGENT_KNOWLEDGE_BASE.md",
51
+ "docs/TESTING.md",
52
+ "docs/schemas/document-operations.schema.json",
53
+ "docs/validation-reports/2026-09-12-agent-protocol-rollout.md",
48
54
  "index.js",
49
55
  "index.d.ts",
50
56
  "dist/",
@@ -77,10 +83,11 @@
77
83
  "scripts": {
78
84
  "build": "node scripts/build.mjs",
79
85
  "check:types": "tsc -p tsconfig.types.json && node scripts/check-types.mjs",
80
- "lint": "eslint index.js adapters core engine pipeline services orchestration scripts tests",
86
+ "lint": "eslint index.js adapters core engine pipeline services orchestration scripts examples tests",
81
87
  "test:coverage": "c8 --reporter=text --reporter=json --reporter=json-summary --reports-dir=coverage node scripts/run-tests.mjs",
82
88
  "coverage:gaps": "node scripts/report-coverage-gaps.mjs",
83
89
  "benchmark:session": "node scripts/benchmark-operation-session.mjs",
90
+ "benchmark:agent": "node scripts/benchmark-agent-workflow.mjs",
84
91
  "benchmark:targeting": "node scripts/benchmark-targeting-hot-paths.mjs",
85
92
  "benchmark:tests": "node scripts/benchmark-test-runner.mjs",
86
93
  "profile:routes": "node scripts/profile-route-selection.mjs",
@@ -232,6 +232,21 @@ export function computeWordDiffs(originalText, newText, options = {}) {
232
232
  return decodeBmpDiffs(charDiffs, wordArray);
233
233
  }
234
234
 
235
+ /**
236
+ * Computes a character-local diff without semantic cleanup. This is used to
237
+ * refine whitespace-only substitutions that a word-level token groups with
238
+ * adjacent unchanged content (for example, an NBSP beside a hyperlink).
239
+ *
240
+ * @param {string} originalText
241
+ * @param {string} newText
242
+ * @param {{ diffTimeoutSeconds?: number }} [options={}]
243
+ * @returns {Array<[number, string]>}
244
+ */
245
+ export function computeCharacterDiffs(originalText, newText, options = {}) {
246
+ if (originalText === newText) return [[0, originalText]];
247
+ return createDiffEngine(options).diff_main(originalText, newText);
248
+ }
249
+
235
250
  /**
236
251
  * Returns a character-local diff only when the modified string can be made
237
252
  * solely by inserting into the original. This prevents word-token cleanup
@@ -76,7 +76,7 @@ try {
76
76
 
77
77
  # -------------------------------------------------------------------------
78
78
  # Scenario 1: insert-interior
79
- # Author A (Barry) inserts: "amended by this Agreement."
79
+ # Author A (John) inserts: "amended by this Agreement."
80
80
  # Author B (Anson) inserts "MASTER " before "Agreement"
81
81
  # -------------------------------------------------------------------------
82
82
  Write-Host "1. insert-interior"
@@ -85,8 +85,8 @@ try {
85
85
  $doc.Range(0, 0).Text = "Contract terms "
86
86
 
87
87
  # Author A insertion
88
- $global:word.UserName = "Barry Plasteras"
89
- $global:word.UserInitials = "BP"
88
+ $global:word.UserName = "John Doe"
89
+ $global:word.UserInitials = "JD"
90
90
  $doc.TrackRevisions = $true
91
91
  $endR = $doc.Range($doc.Content.End - 1, $doc.Content.End - 1)
92
92
  $endR.Text = "amended by this Agreement."
@@ -103,7 +103,7 @@ try {
103
103
 
104
104
  # -------------------------------------------------------------------------
105
105
  # Scenario 2: delete-interior
106
- # Author A (Barry) inserts: "The Services will process the Input to generate outputs for Customer."
106
+ # Author A (John) inserts: "The Platform will process the Data to generate deliverables for Client."
107
107
  # Author B (Anson) deletes: "generate "
108
108
  # -------------------------------------------------------------------------
109
109
  Write-Host "2. delete-interior"
@@ -112,11 +112,11 @@ try {
112
112
  $doc.Range(0, 0).Text = "Background. "
113
113
 
114
114
  # Author A insertion
115
- $global:word.UserName = "Barry Plasteras"
116
- $global:word.UserInitials = "BP"
115
+ $global:word.UserName = "John Doe"
116
+ $global:word.UserInitials = "JD"
117
117
  $doc.TrackRevisions = $true
118
118
  $endR = $doc.Range($doc.Content.End - 1, $doc.Content.End - 1)
119
- $endR.Text = "The Services will process the Input to generate outputs for Customer."
119
+ $endR.Text = "The Platform will process the Data to generate deliverables for Client."
120
120
 
121
121
  # Author B deletion inside Author A's insertion
122
122
  $global:word.UserName = "Anson Lai"
@@ -129,8 +129,8 @@ try {
129
129
 
130
130
  # -------------------------------------------------------------------------
131
131
  # Scenario 3: delete-boundary-start
132
- # Author A (Barry) inserts: "Notwithstanding the foregoing, the NDA remains in effect."
133
- # Author B (Anson) deletes: "Notwithstanding the foregoing, "
132
+ # Author A (John) inserts: "Notwithstanding anything to the contrary, the confidentiality terms remain in effect."
133
+ # Author B (Anson) deletes: "Notwithstanding anything to the contrary, "
134
134
  # -------------------------------------------------------------------------
135
135
  Write-Host "3. delete-boundary-start"
136
136
  $doc = $global:word.Documents.Add()
@@ -138,16 +138,16 @@ try {
138
138
  $doc.Range(0, 0).Text = "Section 1. "
139
139
 
140
140
  # Author A insertion
141
- $global:word.UserName = "Barry Plasteras"
142
- $global:word.UserInitials = "BP"
141
+ $global:word.UserName = "John Doe"
142
+ $global:word.UserInitials = "JD"
143
143
  $doc.TrackRevisions = $true
144
144
  $endR = $doc.Range($doc.Content.End - 1, $doc.Content.End - 1)
145
- $endR.Text = "Notwithstanding the foregoing, the NDA remains in effect."
145
+ $endR.Text = "Notwithstanding anything to the contrary, the confidentiality terms remain in effect."
146
146
 
147
147
  # Author B deletion at start of insertion
148
148
  $global:word.UserName = "Anson Lai"
149
149
  $global:word.UserInitials = "AL"
150
- $delWord = "Notwithstanding the foregoing, "
150
+ $delWord = "Notwithstanding anything to the contrary, "
151
151
  $foundPos = Find-RequiredText $doc $delWord "delete-boundary-start"
152
152
  $delRange = $doc.Range($foundPos, $foundPos + $delWord.Length)
153
153
  $delRange.Delete() | Out-Null
@@ -155,8 +155,8 @@ try {
155
155
 
156
156
  # -------------------------------------------------------------------------
157
157
  # Scenario 4: delete-boundary-end
158
- # Author A (Barry) inserts: "subject to Section 2.8 and applicable law."
159
- # Author B (Anson) deletes: " and applicable law."
158
+ # Author A (John) inserts: "subject to Section 4.2 and applicable standards."
159
+ # Author B (Anson) deletes: " and applicable standards."
160
160
  # -------------------------------------------------------------------------
161
161
  Write-Host "4. delete-boundary-end"
162
162
  $doc = $global:word.Documents.Add()
@@ -164,16 +164,16 @@ try {
164
164
  $doc.Range(0, 0).Text = "Compliance: "
165
165
 
166
166
  # Author A insertion
167
- $global:word.UserName = "Barry Plasteras"
168
- $global:word.UserInitials = "BP"
167
+ $global:word.UserName = "John Doe"
168
+ $global:word.UserInitials = "JD"
169
169
  $doc.TrackRevisions = $true
170
170
  $endR = $doc.Range($doc.Content.End - 1, $doc.Content.End - 1)
171
- $endR.Text = "subject to Section 2.8 and applicable law."
171
+ $endR.Text = "subject to Section 4.2 and applicable standards."
172
172
 
173
173
  # Author B deletion at end of insertion
174
174
  $global:word.UserName = "Anson Lai"
175
175
  $global:word.UserInitials = "AL"
176
- $delWord = " and applicable law."
176
+ $delWord = " and applicable standards."
177
177
  $foundPos = Find-RequiredText $doc $delWord "delete-boundary-end"
178
178
  $delRange = $doc.Range($foundPos, $foundPos + $delWord.Length)
179
179
  $delRange.Delete() | Out-Null
@@ -182,7 +182,7 @@ try {
182
182
  # -------------------------------------------------------------------------
183
183
  # Scenario 5: delete-straddle-baseline-insertion
184
184
  # Baseline: "Baseline start "
185
- # Author A (Barry) inserts: "inserted finish."
185
+ # Author A (John) inserts: "inserted finish."
186
186
  # Author B (Anson) deletes: "start inserted" (straddling baseline and insertion)
187
187
  # -------------------------------------------------------------------------
188
188
  Write-Host "5. delete-straddle-baseline-insertion"
@@ -191,8 +191,8 @@ try {
191
191
  $doc.Range(0, 0).Text = "Baseline start "
192
192
 
193
193
  # Author A insertion
194
- $global:word.UserName = "Barry Plasteras"
195
- $global:word.UserInitials = "BP"
194
+ $global:word.UserName = "John Doe"
195
+ $global:word.UserInitials = "JD"
196
196
  $doc.TrackRevisions = $true
197
197
  $endR = $doc.Range($doc.Content.End - 1, $doc.Content.End - 1)
198
198
  $endR.Text = "inserted finish."
@@ -209,7 +209,7 @@ try {
209
209
  # -------------------------------------------------------------------------
210
210
  # Scenario 6: multi-author-stacked
211
211
  # Baseline: "Provision "
212
- # Author A (Barry) inserts: "first draft of the proposal with initial metrics."
212
+ # Author A (John) inserts: "first draft of the proposal with initial metrics."
213
213
  # Author B (Anson) deletes: "of the proposal "
214
214
  # Author C (Chris) deletes: "initial " from the remaining text
215
215
  # -------------------------------------------------------------------------
@@ -219,8 +219,8 @@ try {
219
219
  $doc.Range(0, 0).Text = "Provision "
220
220
 
221
221
  # Author A insertion
222
- $global:word.UserName = "Barry Plasteras"
223
- $global:word.UserInitials = "BP"
222
+ $global:word.UserName = "John Doe"
223
+ $global:word.UserInitials = "JD"
224
224
  $doc.TrackRevisions = $true
225
225
  $endR = $doc.Range($doc.Content.End - 1, $doc.Content.End - 1)
226
226
  $endR.Text = "first draft of the proposal with initial metrics."
@@ -19,10 +19,12 @@ import {
19
19
  computeDocumentPartsRevisionToken,
20
20
  areRevisionTokensEqual
21
21
  } from './revision-token.js';
22
- import {
23
- createEmptyReceipt,
24
- reconcileReceiptsAgainstOutput
25
- } from './receipt-collector.js';
22
+ import {
23
+ createEmptyReceipt,
24
+ reconcileReceiptsAgainstOutput
25
+ } from './receipt-collector.js';
26
+ import { compileOperationBatch } from './operation-batch-compiler.js';
27
+ import { createRetryPlan } from './error-recovery.js';
26
28
 
27
29
  const NS_W = 'http://schemas.openxmlformats.org/wordprocessingml/2006/main';
28
30
 
@@ -70,9 +72,10 @@ export function buildOperationDependencyPlan(operations = []) {
70
72
  }
71
73
 
72
74
  // 2. Build dependency edges
73
- const inDegrees = new Array(list.length).fill(0);
74
- const dependents = Array.from({ length: list.length }, () => new Set());
75
- const dependencies = Array.from({ length: list.length }, () => new Set());
75
+ const inDegrees = new Array(list.length).fill(0);
76
+ const dependents = Array.from({ length: list.length }, () => new Set());
77
+ const dependencies = Array.from({ length: list.length }, () => new Set());
78
+ const mutatingCaptureConsumers = new Map();
76
79
 
77
80
  for (let i = 0; i < list.length; i++) {
78
81
  const op = list[i];
@@ -107,13 +110,40 @@ export function buildOperationDependencyPlan(operations = []) {
107
110
  }
108
111
  };
109
112
  }
110
- if (!dependencies[i].has(producerIndex)) {
111
- dependencies[i].add(producerIndex);
112
- dependents[producerIndex].add(i);
113
- inDegrees[i]++;
114
- }
115
- }
116
- }
113
+ if (!dependencies[i].has(producerIndex)) {
114
+ dependencies[i].add(producerIndex);
115
+ dependents[producerIndex].add(i);
116
+ inDegrees[i]++;
117
+ }
118
+ const kind = normalizeDocumentOperation(op).operationKind;
119
+ if (kind !== 'comment' && kind !== 'comment_reply') {
120
+ if (!mutatingCaptureConsumers.has(ref)) mutatingCaptureConsumers.set(ref, []);
121
+ mutatingCaptureConsumers.get(ref).push({
122
+ index: i,
123
+ select: op?.target?.select ?? op?.targetDescriptor?.select ?? null
124
+ });
125
+ }
126
+ }
127
+ }
128
+
129
+ for (const [captureRef, consumers] of mutatingCaptureConsumers) {
130
+ if (consumers.length < 2) continue;
131
+ const selectors = consumers.map(consumer => consumer.select);
132
+ const distinctSelectors = new Set(selectors);
133
+ if (selectors.every(selector => typeof selector === 'string' && selector.length > 0)
134
+ && distinctSelectors.size === selectors.length) {
135
+ continue;
136
+ }
137
+ return {
138
+ valid: false,
139
+ error: {
140
+ code: 'CAPTURE_FANOUT_CONFLICT',
141
+ message: `Capture "${captureRef}" has overlapping or unscoped mutating consumers at operation indices ${consumers.map(consumer => consumer.index + 1).join(', ')}.`,
142
+ operationIndexes: consumers.map(consumer => consumer.index + 1),
143
+ captureRef
144
+ }
145
+ };
146
+ }
117
147
 
118
148
  // 3. Stable topological sort with comment priority among ready nodes
119
149
  const ready = [];
@@ -191,62 +221,56 @@ export async function applyOperationsToDocumentXml(documentXml, operations, auth
191
221
  i + 1,
192
222
  op?.operationId,
193
223
  resolveDocumentOperationAuthor(op, author, defaultAuthor),
194
- 'not_attempted'
224
+ 'not_attempted'
195
225
  ));
196
-
197
- if (options.existingRevisions != null && !isExistingRevisionsPolicy(options.existingRevisions)) {
226
+ const failedBeforeExecution = (error, extra = {}) => {
227
+ const receipts = emptyReceipts();
198
228
  return {
199
229
  documentXml,
200
230
  hasChanges: false,
201
231
  commentsXml: null,
202
232
  numberingXmlParts: [],
203
233
  results: [],
204
- receipts: emptyReceipts(),
234
+ receipts,
205
235
  executionOrder: [],
206
236
  authorsUsed: [],
237
+ ...(options.atomic === true ? { rolledBack: true } : {}),
207
238
  status: 'error',
208
- error: {
209
- code: 'INVALID_OPERATION',
210
- message: `Unsupported existingRevisions policy: "${String(options.existingRevisions)}".`
211
- }
239
+ error: normalizeOperationError(error),
240
+ retryPlan: createRetryPlan({
241
+ atomic: options.atomic === true,
242
+ rolledBack: options.atomic === true,
243
+ results: [],
244
+ receipts,
245
+ operationCount: sourceOperations.length
246
+ }),
247
+ ...extra
212
248
  };
249
+ };
250
+
251
+ if (options.existingRevisions != null && !isExistingRevisionsPolicy(options.existingRevisions)) {
252
+ return failedBeforeExecution({
253
+ code: 'INVALID_OPERATION',
254
+ message: `Unsupported existingRevisions policy: "${String(options.existingRevisions)}".`,
255
+ field: 'existingRevisions'
256
+ });
213
257
  }
214
258
 
215
259
  if (options?.expectedRevision) {
216
260
  const tokenValidation = validateRevisionToken(options.expectedRevision);
217
261
  if (!tokenValidation.valid) {
218
- return {
219
- documentXml,
220
- hasChanges: false,
221
- commentsXml: null,
222
- numberingXmlParts: [],
223
- results: [],
224
- receipts: emptyReceipts(),
225
- executionOrder: [],
226
- authorsUsed: [],
227
- status: 'error',
228
- error: {
229
- code: tokenValidation.error?.code || 'INVALID_REVISION_TOKEN',
230
- message: tokenValidation.error?.message || 'Invalid revision token.'
231
- }
232
- };
262
+ return failedBeforeExecution({
263
+ code: tokenValidation.error?.code || 'INVALID_REVISION_TOKEN',
264
+ message: tokenValidation.error?.message || 'Invalid revision token.'
265
+ });
233
266
  }
234
267
  if (options.expectedRevision.scope !== 'document-parts') {
235
- return {
236
- documentXml,
237
- hasChanges: false,
238
- commentsXml: null,
239
- numberingXmlParts: [],
240
- results: [],
241
- receipts: emptyReceipts(),
242
- executionOrder: [],
243
- authorsUsed: [],
244
- status: 'error',
245
- error: {
246
- code: 'REVISION_TOKEN_SCOPE_MISMATCH',
247
- message: `Revision token scope mismatch: expected 'document-parts', got '${options.expectedRevision.scope}'.`
248
- }
249
- };
268
+ return failedBeforeExecution({
269
+ code: 'REVISION_TOKEN_SCOPE_MISMATCH',
270
+ message: `Revision token scope mismatch: expected 'document-parts', got '${options.expectedRevision.scope}'.`,
271
+ expectedScope: 'document-parts',
272
+ actualScope: options.expectedRevision.scope
273
+ });
250
274
  }
251
275
  const currentToken = await computeDocumentPartsRevisionToken({
252
276
  documentXml,
@@ -256,21 +280,12 @@ export async function applyOperationsToDocumentXml(documentXml, operations, auth
256
280
  stylesXml: runtimeContext?.stylesXml || options.stylesXml
257
281
  }, options);
258
282
  if (!areRevisionTokensEqual(currentToken.value, options.expectedRevision.value)) {
259
- return {
260
- documentXml,
261
- hasChanges: false,
262
- commentsXml: null,
263
- numberingXmlParts: [],
264
- results: [],
265
- receipts: emptyReceipts(),
266
- executionOrder: [],
267
- authorsUsed: [],
268
- status: 'error',
269
- error: {
270
- code: 'REVISION_MISMATCH',
271
- message: `Document revision mismatch: expected '${options.expectedRevision.value}', current is '${currentToken.value}'.`
272
- }
273
- };
283
+ return failedBeforeExecution({
284
+ code: 'REVISION_MISMATCH',
285
+ message: `Document revision mismatch: expected '${options.expectedRevision.value}', current is '${currentToken.value}'.`,
286
+ expectedRevision: options.expectedRevision,
287
+ currentRevision: currentToken
288
+ });
274
289
  }
275
290
  }
276
291
 
@@ -281,34 +296,73 @@ export async function applyOperationsToDocumentXml(documentXml, operations, auth
281
296
  _deferDocumentSerialization: true
282
297
  });
283
298
  if (!session.valid) {
284
- return {
285
- documentXml,
286
- hasChanges: false,
287
- commentsXml: null,
288
- numberingXmlParts: [],
289
- results: [],
290
- receipts: emptyReceipts(),
291
- executionOrder: [],
292
- authorsUsed: [],
293
- status: 'error',
294
- error: session.parseResult.error,
295
- warnings: session.parseResult.warnings
296
- };
299
+ return failedBeforeExecution(session.parseResult.error, { warnings: session.parseResult.warnings });
297
300
  }
298
- const dependencyPlan = buildOperationDependencyPlan(sourceOperations);
301
+ const compilation = compileOperationBatch(session.document, sourceOperations, {
302
+ strictTargets: options.strictTargets !== false,
303
+ onInfo: options.onInfo,
304
+ onWarn: options.onWarn
305
+ });
306
+ session.sourceTargetRegistry = compilation.registry;
307
+ if (compilation.conflicts.length > 0) {
308
+ const conflictByIndex = new Map();
309
+ for (const conflict of compilation.conflicts) {
310
+ for (const index of conflict.operationIndexes) {
311
+ if (!conflictByIndex.has(index)) conflictByIndex.set(index, conflict);
312
+ }
313
+ }
314
+ const conflictResults = Array.from(conflictByIndex, ([index, conflict]) => {
315
+ const operation = sourceOperations[index - 1];
316
+ const authorUsed = resolveDocumentOperationAuthor(operation, author, defaultAuthor);
317
+ const normalizedConflict = normalizeOperationError(conflict, {
318
+ operationIndex: index,
319
+ ...(operation?.operationId ? { operationId: operation.operationId } : {})
320
+ });
321
+ return {
322
+ index,
323
+ type: operation?.type || 'redline',
324
+ operationType: normalizeDocumentOperation(operation).operationKind,
325
+ status: 'error',
326
+ authorUsed,
327
+ error: normalizedConflict,
328
+ receipt: createEmptyReceipt(index, operation?.operationId, authorUsed, 'refused')
329
+ };
330
+ }).sort((left, right) => left.index - right.index);
331
+ const receipts = sourceOperations.map((operation, index) => {
332
+ const conflictResult = conflictResults.find(result => result.index === index + 1);
333
+ return conflictResult?.receipt || createEmptyReceipt(
334
+ index + 1,
335
+ operation?.operationId,
336
+ resolveDocumentOperationAuthor(operation, author, defaultAuthor),
337
+ 'not_attempted'
338
+ );
339
+ });
340
+ return {
341
+ documentXml,
342
+ hasChanges: false,
343
+ commentsXml: null,
344
+ numberingXmlParts: [],
345
+ results: conflictResults,
346
+ receipts,
347
+ executionOrder: [],
348
+ authorsUsed: [],
349
+ rolledBack: options.atomic === true,
350
+ status: 'error',
351
+ error: normalizeOperationError(compilation.conflicts[0]),
352
+ conflicts: compilation.conflicts.map(conflict => normalizeOperationError(conflict)),
353
+ retryPlan: createRetryPlan({
354
+ atomic: options.atomic === true,
355
+ rolledBack: options.atomic === true,
356
+ results: conflictResults,
357
+ receipts,
358
+ operationCount: sourceOperations.length
359
+ })
360
+ };
361
+ }
362
+ const executableOperations = compilation.compiledOperations;
363
+ const dependencyPlan = buildOperationDependencyPlan(executableOperations);
299
364
  if (!dependencyPlan.valid) {
300
- return {
301
- documentXml,
302
- hasChanges: false,
303
- commentsXml: null,
304
- numberingXmlParts: [],
305
- results: [],
306
- receipts: emptyReceipts(),
307
- executionOrder: [],
308
- authorsUsed: [],
309
- status: 'error',
310
- error: dependencyPlan.error
311
- };
365
+ return failedBeforeExecution(dependencyPlan.error);
312
366
  }
313
367
  const scheduled = dependencyPlan.scheduled;
314
368
 
@@ -332,9 +386,37 @@ export async function applyOperationsToDocumentXml(documentXml, operations, auth
332
386
  const authorsUsed = session.authorsUsed;
333
387
  let operationFailed = false;
334
388
 
335
- for (const { operation, index } of scheduled) {
336
- executionOrder.push(index + 1);
337
- try {
389
+ for (const { operation, index } of scheduled) {
390
+ executionOrder.push(index + 1);
391
+ const sourceBinding = compilation.bindings[index];
392
+ if (sourceBinding?.error) {
393
+ operationFailed = true;
394
+ const authorUsed = resolveDocumentOperationAuthor(operation, author, defaultAuthor);
395
+ const errorReceipt = createEmptyReceipt(
396
+ index + 1,
397
+ operation?.operationId,
398
+ authorUsed,
399
+ 'refused'
400
+ );
401
+ const normalizedBindingError = normalizeOperationError(sourceBinding.error, {
402
+ operationIndex: index + 1,
403
+ ...(operation?.operationId ? { operationId: operation.operationId } : {})
404
+ });
405
+ errorReceipt.warnings.push(normalizedBindingError.message);
406
+ results.push({
407
+ index: index + 1,
408
+ type: operation?.type || 'redline',
409
+ status: 'error',
410
+ operationType: normalizeDocumentOperation(operation).operationKind,
411
+ authorUsed,
412
+ warnings: [normalizedBindingError.message],
413
+ error: normalizedBindingError,
414
+ receipt: errorReceipt
415
+ });
416
+ if (!continueOnError) break;
417
+ continue;
418
+ }
419
+ try {
338
420
  const result = await applyOperationToDocumentXml(
339
421
  session.currentDocumentXml,
340
422
  operation,
@@ -374,8 +456,11 @@ export async function applyOperationsToDocumentXml(documentXml, operations, auth
374
456
  ...(result.receipt ? { receipt: result.receipt } : {})
375
457
  });
376
458
  if (isError && !continueOnError) break;
377
- } catch (error) {
378
- const normalizedError = normalizeOperationError(error);
459
+ } catch (error) {
460
+ const normalizedError = normalizeOperationError(error, {
461
+ operationIndex: index + 1,
462
+ ...(operation?.operationId ? { operationId: operation.operationId } : {})
463
+ });
379
464
  operationFailed = true;
380
465
  const authorUsed = resolveDocumentOperationAuthor(operation, author, getDefaultAuthor());
381
466
  const errorReceipt = createEmptyReceipt(
@@ -476,8 +561,8 @@ export async function applyOperationsToDocumentXml(documentXml, operations, auth
476
561
 
477
562
  if (!rolledBack) commitBatchRuntimeContext(runtimeContext, context);
478
563
 
479
- return {
480
- documentXml: rolledBack ? session.rollback() : outputDocumentXml,
564
+ const batchResult = {
565
+ documentXml: rolledBack ? session.rollback() : outputDocumentXml,
481
566
  hasChanges: rolledBack ? false : hasChanges,
482
567
  commentsXml: rolledBack ? null : session.commentsXml,
483
568
  commentsExtendedXml: rolledBack ? null : session.commentsExtendedXml,
@@ -491,23 +576,33 @@ export async function applyOperationsToDocumentXml(documentXml, operations, auth
491
576
  ...(rolledBack ? {
492
577
  rolledBack: true,
493
578
  status: 'error',
494
- error: {
495
- code: reconciliationError ? reconciliationError.code : (serializationError ? 'DOCUMENT_SERIALIZATION_FAILED' : 'BATCH_OPERATION_FAILED'),
496
- message: reconciliationError?.message
497
- || serializationError?.message
498
- || 'Atomic batch rolled back because one or more operations failed.'
499
- }
500
- } : (reconciliationError ? {
501
- status: 'error',
502
- error: reconciliationError
503
- } : (operationFailed ? {
504
- status: hasChanges ? 'partial' : 'error',
505
- error: {
506
- code: 'BATCH_OPERATION_FAILED',
507
- message: 'One or more operations failed.'
508
- }
509
- } : {
510
- status: 'ok'
511
- })))
512
- };
513
- }
579
+ error: normalizeOperationError({
580
+ code: reconciliationError ? reconciliationError.code : (serializationError ? 'DOCUMENT_SERIALIZATION_FAILED' : 'BATCH_OPERATION_FAILED'),
581
+ message: reconciliationError?.message
582
+ || serializationError?.message
583
+ || 'Atomic batch rolled back because one or more operations failed.'
584
+ })
585
+ } : (reconciliationError ? {
586
+ status: 'error',
587
+ error: normalizeOperationError(reconciliationError)
588
+ } : (operationFailed ? {
589
+ status: hasChanges ? 'partial' : 'error',
590
+ error: normalizeOperationError({
591
+ code: 'BATCH_OPERATION_FAILED',
592
+ message: 'One or more operations failed.'
593
+ })
594
+ } : {
595
+ status: 'ok'
596
+ })))
597
+ };
598
+ if (batchResult.status === 'error' || batchResult.status === 'partial') {
599
+ batchResult.retryPlan = createRetryPlan({
600
+ atomic,
601
+ rolledBack,
602
+ results: batchResult.results,
603
+ receipts: batchResult.receipts,
604
+ operationCount: sourceOperations.length
605
+ });
606
+ }
607
+ return batchResult;
608
+ }
@@ -199,9 +199,10 @@ export function inspectDocumentParts(parts, options = {}) {
199
199
  const resolveNumbering = createNumberingResolver(numberingPart.doc);
200
200
  let nearestHeading = null;
201
201
  const paragraphNodes = getDocumentParagraphNodes(documentPart.doc);
202
- const commentAnchors = collectDocumentCommentAnchors(paragraphNodes, options.revisionView || 'accepted');
202
+ const revisionView = options.revisionView === 'rejected' ? 'rejected' : 'accepted';
203
+ const commentAnchors = collectDocumentCommentAnchors(paragraphNodes, revisionView);
203
204
  let paragraphs = paragraphNodes.map((paragraph, zeroIndex) => {
204
- const text = extractCanonicalParagraphText(paragraph, { revisionView: options.revisionView || 'accepted' });
205
+ const text = extractCanonicalParagraphText(paragraph, { revisionView });
205
206
  const level = headingLevel(paragraph);
206
207
  if (level) nearestHeading = { level, text };
207
208
  const ids = [...new Set([...descendants(paragraph, 'commentRangeStart'), ...descendants(paragraph, 'commentReference')].map(node => attr(node, 'id')).filter(Boolean))];
@@ -215,7 +216,8 @@ export function inspectDocumentParts(parts, options = {}) {
215
216
  const humanReference = [provision, headingText, text.slice(0, options.excerptLength || 120)].filter(Boolean).join(' — ');
216
217
  const segments = extractParagraphRevisionSegments(paragraph);
217
218
  return {
218
- index, ref: `P${index}`, paragraphId: getParagraphId(paragraph), fingerprint: createParagraphFingerprint(paragraph),
219
+ index, ref: `P${index}`, paragraphId: getParagraphId(paragraph),
220
+ fingerprint: createParagraphFingerprint(paragraph, { text, index, revisionView }), revisionView,
219
221
  text, exactText: text, excerpt: text.slice(0, options.excerptLength || 120), humanReference, inTable: hasAncestor(paragraph, 'tc'), table: structure.table,
220
222
  styleId, headingLevel: level, nearestHeading, list, structuralReferences: structure.references, hasRevisions: authors.length > 0, revisionAuthors: authors, commentIds: ids,
221
223
  segments