@ansonlai/docx-redline-js 0.5.4 → 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 (51) hide show
  1. package/AGENTS.md +78 -697
  2. package/ARCHITECTURE.md +13 -1
  3. package/CHANGELOG.md +5 -0
  4. package/README.md +140 -30
  5. package/core/paragraph-targeting.js +14 -2
  6. package/dist/docx-redline-js.esm.js +113 -33
  7. package/dist/docx-redline-js.esm.js.map +3 -3
  8. package/dist/docx-redline-js.esm.min.js +75 -75
  9. package/dist/docx-redline-js.esm.min.js.map +4 -4
  10. package/docs/AGENT_FAST_START.md +59 -0
  11. package/docs/AGENT_KNOWLEDGE_BASE.md +868 -0
  12. package/docs/TESTING.md +20 -1
  13. package/docs/schemas/document-operations.schema.json +5 -1
  14. package/docs/validation-reports/2026-09-12-agent-protocol-rollout.md +82 -0
  15. package/engine/oxml-engine.js +80 -13
  16. package/engine/run-builders.js +5 -15
  17. package/index.d.ts +17 -1
  18. package/node/cli.js +111 -27
  19. package/node/docx-document.js +120 -69
  20. package/node/index.d.ts +6 -2
  21. package/package.json +10 -3
  22. package/scripts/generate-cross-author-slicing-fixtures.ps1 +25 -25
  23. package/services/batch-operation-orchestrator.js +215 -120
  24. package/services/document-inspection.js +5 -3
  25. package/services/document-operation-applier.js +52 -34
  26. package/services/document-operation-contract.js +10 -6
  27. package/services/document-operation-mutations.js +51 -5
  28. package/services/document-operation-session.js +4 -0
  29. package/services/error-recovery.js +174 -0
  30. package/services/operation-batch-compiler.js +394 -0
  31. package/services/operation-preflight.js +91 -72
  32. package/services/standalone-operation-runner.d.ts +17 -1
  33. package/docs/plans/2026-09-05-structural-revisions-and-fidelity-oracles.md +0 -1669
  34. package/docs/plans/2026-09-08-cross-author-revision-slicing.md +0 -1399
  35. package/docs/plans/completed/2026-03-01-release-0.1.4-design.md +0 -33
  36. package/docs/plans/completed/2026-03-01-release-0.1.4.md +0 -110
  37. package/docs/plans/completed/2026-05-31-architectural changes.md +0 -593
  38. package/docs/plans/completed/2026-08-02-reliability-improvements.md +0 -1155
  39. package/docs/plans/completed/2026-08-30-reliability-testing-improvements.md +0 -488
  40. package/docs/plans/completed/2026-09-01-performance-and-complexity-reduction.md +0 -669
  41. package/docs/plans/completed/2026-09-03-agent-friendly-document-workflows.md +0 -427
  42. package/docs/plans/completed/2026-09-04-comment-anchor-and-cli-reliability.md +0 -519
  43. package/docs/plans/completed/PERFORMANCE-CONSOLIDATION.md +0 -69
  44. package/docs/plans/completed/structural-revision-capability-matrix.md +0 -115
  45. package/docs/test-comparison-dashboard.html +0 -4338
  46. package/docs/validation-reports/2026-08-30-phase-1-word-visual-preflight.md +0 -22
  47. package/docs/validation-reports/2026-08-30-phase-2-word-visual-preflight.md +0 -24
  48. package/docs/validation-reports/2026-08-30-phase-3-coverage.md +0 -73
  49. package/docs/validation-reports/2026-09-02-multilevel-bullets-visual-review.md +0 -82
  50. package/docs/validation-reports/2026-09-02-multimodal-visual-samples.md +0 -114
  51. package/docs/validation-reports/2026-09-02-visual-failures-preflight.md +0 -79
@@ -0,0 +1,394 @@
1
+ import {
2
+ buildParagraphMetadataIndex,
3
+ getDocumentParagraphNodes,
4
+ getParagraphId,
5
+ resolveTargetParagraph
6
+ } from '../core/paragraph-targeting.js';
7
+ import {
8
+ normalizeDocumentOperation,
9
+ normalizeTargetDescriptor,
10
+ validateDocumentOperation
11
+ } from './document-operation-contract.js';
12
+
13
+ const TEXT_WRITE_KINDS = new Set(['redline', 'restore', 'rejected-insert']);
14
+ const FORMAT_WRITE_KINDS = new Set(['highlight', 'format', 'paragraph-format']);
15
+
16
+ function normalizedError(error) {
17
+ return {
18
+ code: typeof error?.code === 'string' && error.code ? error.code : 'OPERATION_ERROR',
19
+ message: error?.message || String(error),
20
+ ...(Array.isArray(error?.candidates) ? { candidates: error.candidates } : {})
21
+ };
22
+ }
23
+
24
+ function isParagraphAttached(document, paragraph) {
25
+ return !!paragraph && getDocumentParagraphNodes(document).includes(paragraph);
26
+ }
27
+
28
+ function paragraphNodesFromMutation(nodes = []) {
29
+ const paragraphs = [];
30
+ for (const node of nodes) {
31
+ if (!node || node.nodeType !== 1) continue;
32
+ if (node.localName === 'p') paragraphs.push(node);
33
+ for (const paragraph of Array.from(node.getElementsByTagNameNS?.('*', 'p') || [])) {
34
+ if (!paragraphs.includes(paragraph)) paragraphs.push(paragraph);
35
+ }
36
+ }
37
+ return paragraphs;
38
+ }
39
+
40
+ /** Session-local source identities over live paragraph nodes. */
41
+ export class SourceTargetRegistry {
42
+ constructor(document) {
43
+ this.document = document;
44
+ this.nextId = 1;
45
+ this.entries = new Map();
46
+ this.idsByNode = new WeakMap();
47
+ }
48
+
49
+ register(paragraph, metadata = {}) {
50
+ const existing = this.idsByNode.get(paragraph);
51
+ if (existing) return existing;
52
+ const sourceId = `S${this.nextId++}`;
53
+ this.entries.set(sourceId, {
54
+ sourceId,
55
+ paragraph,
56
+ sourceIndex: metadata.index || null,
57
+ paragraphId: metadata.paragraphId || null,
58
+ fingerprint: metadata.fingerprint || null,
59
+ text: metadata.text || '',
60
+ revisionView: metadata.revisionView === 'rejected' ? 'rejected' : 'accepted',
61
+ consumed: false,
62
+ consumedByOperation: null
63
+ });
64
+ this.idsByNode.set(paragraph, sourceId);
65
+ return sourceId;
66
+ }
67
+
68
+ describe(sourceId) {
69
+ const entry = this.entries.get(sourceId);
70
+ if (!entry) return null;
71
+ return {
72
+ sourceId,
73
+ index: entry.sourceIndex,
74
+ paragraphId: entry.paragraphId,
75
+ fingerprint: entry.fingerprint,
76
+ text: entry.text,
77
+ revisionView: entry.revisionView
78
+ };
79
+ }
80
+
81
+ resolve(sourceId, document = this.document) {
82
+ const entry = this.entries.get(sourceId);
83
+ if (!entry) {
84
+ return {
85
+ error: {
86
+ code: 'SOURCE_TARGET_NOT_FOUND',
87
+ message: `Compiled source target ${String(sourceId)} was not found.`
88
+ }
89
+ };
90
+ }
91
+ if (entry.consumed) {
92
+ return {
93
+ error: {
94
+ code: 'TARGET_CONSUMED_BY_OPERATION',
95
+ message: `Compiled source target ${sourceId} was consumed by operation ${entry.consumedByOperation}.`,
96
+ sourceTarget: this.describe(sourceId),
97
+ consumedByOperation: entry.consumedByOperation
98
+ }
99
+ };
100
+ }
101
+ if (isParagraphAttached(document, entry.paragraph)) {
102
+ return { paragraph: entry.paragraph, resolvedBy: 'batch_source' };
103
+ }
104
+ return {
105
+ error: {
106
+ code: 'TARGET_CONSUMED_BY_OPERATION',
107
+ message: `Compiled source target ${sourceId} is no longer present in the live document.`,
108
+ sourceTarget: this.describe(sourceId),
109
+ consumedByOperation: entry.consumedByOperation
110
+ }
111
+ };
112
+ }
113
+
114
+ createSavepoint(document = this.document) {
115
+ const paragraphs = getDocumentParagraphNodes(document);
116
+ return new Map(Array.from(this.entries, ([sourceId, entry]) => [sourceId, {
117
+ paragraphIndex: paragraphs.indexOf(entry.paragraph),
118
+ consumed: entry.consumed,
119
+ consumedByOperation: entry.consumedByOperation
120
+ }]));
121
+ }
122
+
123
+ restoreSavepoint(document, snapshot) {
124
+ this.document = document;
125
+ this.idsByNode = new WeakMap();
126
+ const paragraphs = getDocumentParagraphNodes(document);
127
+ for (const [sourceId, entry] of this.entries) {
128
+ const saved = snapshot instanceof Map ? snapshot.get(sourceId) : null;
129
+ entry.consumed = saved?.consumed === true;
130
+ entry.consumedByOperation = saved?.consumedByOperation || null;
131
+ entry.paragraph = Number.isInteger(saved?.paragraphIndex) && saved.paragraphIndex >= 0
132
+ ? (paragraphs[saved.paragraphIndex] || null)
133
+ : null;
134
+ if (entry.paragraph) this.idsByNode.set(entry.paragraph, sourceId);
135
+ }
136
+ }
137
+
138
+ commitMutation(sourceId, removedNodes, liveNodes, operationIndex) {
139
+ const entry = this.entries.get(sourceId);
140
+ if (!entry || !Array.isArray(removedNodes) || !removedNodes.includes(entry.paragraph)) return;
141
+ const candidates = paragraphNodesFromMutation(liveNodes);
142
+ const sameId = entry.paragraphId
143
+ ? candidates.filter(paragraph => getParagraphId(paragraph) === entry.paragraphId)
144
+ : [];
145
+ const successor = sameId.length === 1
146
+ ? sameId[0]
147
+ : (candidates.length === 1 ? candidates[0] : null);
148
+ if (successor) {
149
+ entry.paragraph = successor;
150
+ this.idsByNode.set(successor, sourceId);
151
+ return;
152
+ }
153
+ entry.paragraph = null;
154
+ entry.consumed = true;
155
+ entry.consumedByOperation = operationIndex;
156
+ }
157
+ }
158
+
159
+ function resolvedTarget(metadata, revisionView) {
160
+ return {
161
+ index: metadata.index,
162
+ paragraphId: metadata.paragraphId,
163
+ fingerprint: metadata.fingerprint,
164
+ text: metadata.text,
165
+ inTable: metadata.inTable,
166
+ revisionView
167
+ };
168
+ }
169
+
170
+ function resolveDescriptor(xmlDoc, descriptor, kind, strictTargets, metadataIndices, callbacks = {}) {
171
+ const revisionView = descriptor?.revisionView === 'rejected' ? 'rejected' : 'accepted';
172
+ if (!metadataIndices[revisionView]) {
173
+ metadataIndices[revisionView] = buildParagraphMetadataIndex(xmlDoc, { revisionView });
174
+ }
175
+ const index = metadataIndices[revisionView];
176
+ const resolution = resolveTargetParagraph(xmlDoc, {
177
+ targetText: descriptor.text,
178
+ targetRef: descriptor.index,
179
+ targetDescriptor: descriptor,
180
+ opType: kind,
181
+ strictAmbiguity: strictTargets,
182
+ paragraphMetadataIndex: index,
183
+ metadataIndices,
184
+ onInfo: callbacks.onInfo,
185
+ onWarn: callbacks.onWarn
186
+ });
187
+ const metadata = index.byParagraph.get(resolution.paragraph);
188
+ return {
189
+ paragraph: resolution.paragraph,
190
+ resolvedBy: resolution.resolvedBy,
191
+ metadata: resolvedTarget(metadata, revisionView)
192
+ };
193
+ }
194
+
195
+ function buildConflict(code, message, bindings, target, action) {
196
+ return {
197
+ code,
198
+ message,
199
+ operationIndexes: bindings.map(binding => binding.index).sort((a, b) => a - b),
200
+ target,
201
+ recovery: {
202
+ action,
203
+ sameArgumentsSafe: false,
204
+ requiresReinspection: false,
205
+ requiresUserAuthorization: false
206
+ }
207
+ };
208
+ }
209
+
210
+ /** Resolve source targets once and derive source-level conflicts before mutation. */
211
+ export function compileOperationBatch(xmlDoc, operations = [], options = {}) {
212
+ const sourceOperations = Array.isArray(operations) ? operations : [];
213
+ const strictTargets = options.strictTargets !== false;
214
+ const registry = new SourceTargetRegistry(xmlDoc);
215
+ const metadataIndices = {
216
+ accepted: buildParagraphMetadataIndex(xmlDoc, { revisionView: 'accepted' }),
217
+ rejected: null
218
+ };
219
+ const bindings = [];
220
+ const compiledOperations = [];
221
+
222
+ for (let index = 0; index < sourceOperations.length; index += 1) {
223
+ const sourceOperation = sourceOperations[index];
224
+ const validation = validateDocumentOperation(sourceOperation);
225
+ const operation = validation.operation || normalizeDocumentOperation(sourceOperation);
226
+ const binding = {
227
+ index: index + 1,
228
+ operationKind: operation.operationKind,
229
+ operationId: operation.operationId,
230
+ sourceIds: [],
231
+ warnings: []
232
+ };
233
+ const compiled = { ...sourceOperation };
234
+
235
+ const dynamicOccurrence = operation.targetDescriptor?.occurrence != null
236
+ && !operation.targetDescriptor?.paragraphId
237
+ && !operation.targetDescriptor?.fingerprint;
238
+ const compositeTextTarget = typeof operation.targetDescriptor?.text === 'string'
239
+ && /\r|\n/.test(operation.targetDescriptor.text)
240
+ && !operation.targetEndDescriptor;
241
+ if (dynamicOccurrence || compositeTextTarget) binding.dynamic = true;
242
+
243
+ if (
244
+ validation.valid
245
+ && operation.operationKind !== 'comment_reply'
246
+ && !operation.targetDescriptor?.captureRef
247
+ && !binding.dynamic
248
+ ) {
249
+ try {
250
+ const start = resolveDescriptor(
251
+ xmlDoc,
252
+ operation.targetDescriptor,
253
+ operation.operationKind,
254
+ strictTargets,
255
+ metadataIndices,
256
+ {
257
+ onInfo: options.onInfo,
258
+ onWarn: warning => binding.warnings.push(String(warning))
259
+ }
260
+ );
261
+ const startId = registry.register(start.paragraph, start.metadata);
262
+ binding.sourceIds.push(startId);
263
+ binding.resolvedBy = start.resolvedBy;
264
+ binding.resolvedTarget = start.metadata;
265
+ compiled._compiledSourceId = startId;
266
+ compiled._compiledResolvedBy = start.resolvedBy;
267
+ if (binding.warnings.length > 0) compiled._compiledWarnings = binding.warnings;
268
+
269
+ const targetEndDescriptor = operation.targetEndDescriptor
270
+ || (operation.targetEndRef != null
271
+ ? normalizeTargetDescriptor(null, operation.targetEndRef, operation.targetDescriptor.revisionView)
272
+ : null);
273
+ if (targetEndDescriptor) {
274
+ const end = resolveDescriptor(
275
+ xmlDoc,
276
+ targetEndDescriptor,
277
+ operation.operationKind,
278
+ strictTargets,
279
+ metadataIndices,
280
+ {
281
+ onInfo: options.onInfo,
282
+ onWarn: warning => binding.warnings.push(String(warning))
283
+ }
284
+ );
285
+ const endId = registry.register(end.paragraph, end.metadata);
286
+ compiled._compiledSourceEndId = endId;
287
+ const paragraphs = getDocumentParagraphNodes(xmlDoc);
288
+ const startIndex = paragraphs.indexOf(start.paragraph);
289
+ const endIndex = paragraphs.indexOf(end.paragraph);
290
+ if (startIndex < 0 || endIndex < startIndex) {
291
+ throw Object.assign(new Error('Target range is not a forward contiguous paragraph range.'), {
292
+ code: 'TARGET_RANGE_INVALID'
293
+ });
294
+ }
295
+ for (const paragraph of paragraphs.slice(startIndex, endIndex + 1)) {
296
+ const viewIndex = metadataIndices[operation.targetDescriptor.revisionView] || metadataIndices.accepted;
297
+ const metadata = viewIndex.byParagraph.get(paragraph);
298
+ binding.sourceIds.push(registry.register(paragraph, metadata || {}));
299
+ }
300
+ binding.sourceIds = [...new Set(binding.sourceIds)];
301
+ binding.resolvedTargetEnd = end.metadata;
302
+ }
303
+ } catch (error) {
304
+ binding.error = normalizedError(error);
305
+ }
306
+ }
307
+ bindings.push(binding);
308
+ compiledOperations.push(compiled);
309
+ }
310
+
311
+ const existingCaptureKeys = new Set(compiledOperations.map(operation => operation?.captureKey).filter(Boolean));
312
+ let nextImplicitCapture = 1;
313
+ for (const binding of bindings) {
314
+ if (binding.error?.code !== 'TARGET_NOT_FOUND') continue;
315
+ const consumerIndex = binding.index - 1;
316
+ const consumer = normalizeDocumentOperation(sourceOperations[consumerIndex]);
317
+ const targetText = consumer.targetDescriptor?.text;
318
+ if (!targetText) continue;
319
+ const producers = sourceOperations.flatMap((candidate, producerIndex) => {
320
+ if (producerIndex === consumerIndex || typeof candidate?.modified !== 'string') return [];
321
+ const createdParagraphs = candidate.modified.split(/\r?\n/).map(text => text.trim()).filter(Boolean);
322
+ return createdParagraphs.includes(targetText.trim()) ? [{ candidate, producerIndex }] : [];
323
+ });
324
+ if (producers.length !== 1) continue;
325
+ const [{ candidate: producer, producerIndex }] = producers;
326
+ let captureKey = producer.captureKey || compiledOperations[producerIndex].captureKey;
327
+ if (!captureKey) {
328
+ do {
329
+ captureKey = `__batch_created_${nextImplicitCapture++}`;
330
+ } while (existingCaptureKeys.has(captureKey));
331
+ existingCaptureKeys.add(captureKey);
332
+ compiledOperations[producerIndex].captureKey = captureKey;
333
+ }
334
+ compiledOperations[consumerIndex].target = { captureRef: captureKey, select: targetText };
335
+ delete compiledOperations[consumerIndex]._compiledSourceId;
336
+ delete compiledOperations[consumerIndex]._compiledResolvedBy;
337
+ delete binding.error;
338
+ binding.sourceIds = [];
339
+ binding.resolvedBy = 'created_content_dependency';
340
+ binding.createdByOperation = producerIndex + 1;
341
+ binding.captureRef = captureKey;
342
+ }
343
+
344
+ const bySource = new Map();
345
+ for (const binding of bindings) {
346
+ if (binding.error || binding.dynamic) continue;
347
+ for (const sourceId of binding.sourceIds) {
348
+ if (!bySource.has(sourceId)) bySource.set(sourceId, []);
349
+ bySource.get(sourceId).push(binding);
350
+ }
351
+ }
352
+
353
+ const conflicts = [];
354
+ const conflictKeys = new Set();
355
+ for (const [sourceId, sourceBindings] of bySource) {
356
+ const textWrites = sourceBindings.filter(binding => TEXT_WRITE_KINDS.has(binding.operationKind));
357
+ const formatWrites = sourceBindings.filter(binding => FORMAT_WRITE_KINDS.has(binding.operationKind));
358
+ if (textWrites.length > 1) {
359
+ const key = `text:${textWrites.map(binding => binding.index).sort().join(',')}`;
360
+ if (!conflictKeys.has(key)) {
361
+ conflictKeys.add(key);
362
+ conflicts.push(buildConflict(
363
+ 'OVERLAPPING_SOURCE_TARGETS',
364
+ `Operations ${textWrites.map(binding => binding.index).join(', ')} contain incompatible text writes to the same batch-start source target.`,
365
+ textWrites,
366
+ registry.describe(sourceId),
367
+ 'consolidate_operations'
368
+ ));
369
+ }
370
+ }
371
+ if (textWrites.length > 0 && formatWrites.length > 0) {
372
+ const affected = [...textWrites, ...formatWrites];
373
+ const key = `format:${affected.map(binding => binding.index).sort().join(',')}`;
374
+ if (!conflictKeys.has(key)) {
375
+ conflictKeys.add(key);
376
+ conflicts.push(buildConflict(
377
+ 'REVISION_ORDER_CONFLICT',
378
+ `Operations ${affected.map(binding => binding.index).join(', ')} combine text and formatting writes on the same batch-start source target.`,
379
+ affected,
380
+ registry.describe(sourceId),
381
+ 'split_or_consolidate_operations'
382
+ ));
383
+ }
384
+ }
385
+ }
386
+
387
+ return {
388
+ valid: conflicts.length === 0 && bindings.every(binding => !binding.error),
389
+ compiledOperations,
390
+ bindings,
391
+ conflicts,
392
+ registry
393
+ };
394
+ }
@@ -29,14 +29,12 @@ import {
29
29
  resolveDocumentOperationAuthor,
30
30
  validateDocumentOperation
31
31
  } from './document-operation-contract.js';
32
- import { buildOperationDependencyPlan } from './batch-operation-orchestrator.js';
33
-
34
- function normalizedError(error) {
35
- return {
36
- code: typeof error?.code === 'string' && error.code ? error.code : 'OPERATION_ERROR',
37
- message: error?.message || String(error),
38
- ...(Array.isArray(error?.candidates) ? { candidates: error.candidates } : {})
39
- };
32
+ import { buildOperationDependencyPlan } from './batch-operation-orchestrator.js';
33
+ import { compileOperationBatch } from './operation-batch-compiler.js';
34
+ import { normalizeErrorWithRecovery } from './error-recovery.js';
35
+
36
+ function normalizedError(error, context = {}) {
37
+ return normalizeErrorWithRecovery(error, context);
40
38
  }
41
39
 
42
40
  function operationNeedsNumbering(operation) {
@@ -70,10 +68,6 @@ function targetMetadata(xmlDoc, paragraph, resolvedBy, suppliedText, paragraphMe
70
68
  };
71
69
  }
72
70
 
73
- function buildConflict(code, message, operationIndexes, target) {
74
- return { code, message, operationIndexes, target };
75
- }
76
-
77
71
  function getCommentIdsInParagraph(paragraph) {
78
72
  const ids = new Set();
79
73
  for (const localName of ['commentRangeStart', 'commentRangeEnd', 'commentReference']) {
@@ -90,10 +84,10 @@ export function preflightOperations(documentXml, operations, author, options = {
90
84
  return {
91
85
  valid: false,
92
86
  status: 'error',
93
- error: {
87
+ error: normalizedError({
94
88
  code: 'INVALID_OPERATION',
95
89
  message: `Unsupported existingRevisions policy: "${String(options.existingRevisions)}".`
96
- },
90
+ }),
97
91
  results: [],
98
92
  conflicts: [],
99
93
  authorsUsed: [],
@@ -105,7 +99,7 @@ export function preflightOperations(documentXml, operations, author, options = {
105
99
  return {
106
100
  valid: false,
107
101
  status: 'error',
108
- error: parsed.error,
102
+ error: normalizedError(parsed.error),
109
103
  results: [],
110
104
  conflicts: [],
111
105
  authorsUsed: [],
@@ -119,19 +113,23 @@ export function preflightOperations(documentXml, operations, author, options = {
119
113
  rejected: null
120
114
  };
121
115
  const sourceOperations = Array.isArray(operations) ? operations : [];
122
- const dependencyPlan = buildOperationDependencyPlan(sourceOperations);
123
- if (!dependencyPlan.valid) {
116
+ const batchCompilation = compileOperationBatch(xmlDoc, sourceOperations, {
117
+ strictTargets: options.strictTargets !== false,
118
+ onInfo: options.onInfo,
119
+ onWarn: options.onWarn
120
+ });
121
+ const dependencyPlan = buildOperationDependencyPlan(batchCompilation.compiledOperations);
122
+ if (!dependencyPlan.valid) {
124
123
  return {
125
124
  valid: false,
126
125
  status: 'error',
127
- error: dependencyPlan.error,
126
+ error: normalizedError(dependencyPlan.error),
128
127
  results: [],
129
128
  conflicts: [],
130
129
  authorsUsed: [],
131
130
  requiredArtifacts: { comments: false, numbering: false }
132
131
  };
133
- }
134
-
132
+ }
135
133
  const strictTargets = options.strictTargets !== false;
136
134
  const results = [];
137
135
  const authorsUsed = new Set();
@@ -175,7 +173,7 @@ export function preflightOperations(documentXml, operations, author, options = {
175
173
  continue;
176
174
  }
177
175
 
178
- if (operation.targetDescriptor?.captureRef) {
176
+ if (operation.targetDescriptor?.captureRef) {
179
177
  results.push({
180
178
  index: index + 1,
181
179
  type: sourceOperation?.type || 'redline',
@@ -186,8 +184,35 @@ export function preflightOperations(documentXml, operations, author, options = {
186
184
  captureRef: operation.targetDescriptor.captureRef,
187
185
  ...(operation.targetDescriptor.select ? { select: operation.targetDescriptor.select } : {})
188
186
  });
189
- continue;
190
- }
187
+ continue;
188
+ }
189
+
190
+ const compiledBinding = batchCompilation.bindings[index];
191
+ if (compiledBinding?.error) {
192
+ results.push({
193
+ index: index + 1,
194
+ type: sourceOperation?.type || 'redline',
195
+ operationType: operation.operationKind,
196
+ status: 'error',
197
+ authorUsed,
198
+ error: compiledBinding.error
199
+ });
200
+ continue;
201
+ }
202
+ if (compiledBinding?.createdByOperation) {
203
+ results.push({
204
+ index: index + 1,
205
+ type: sourceOperation?.type || 'redline',
206
+ operationType: operation.operationKind,
207
+ status: 'deferred',
208
+ authorUsed,
209
+ resolvedBy: 'created_content_dependency',
210
+ captureRef: compiledBinding.captureRef,
211
+ select: operation.targetDescriptor.text,
212
+ createdByOperation: compiledBinding.createdByOperation
213
+ });
214
+ continue;
215
+ }
191
216
 
192
217
  const targetView = operation.targetDescriptor?.revisionView === 'rejected' ? 'rejected' : 'accepted';
193
218
  let currentMetadataIndex = targetView === 'rejected'
@@ -195,17 +220,28 @@ export function preflightOperations(documentXml, operations, author, options = {
195
220
  : metadataIndices.accepted;
196
221
 
197
222
  try {
198
- const resolved = resolveTargetParagraph(xmlDoc, {
199
- targetText: operation.target,
200
- targetRef: operation.targetRef,
201
- targetDescriptor: operation.targetDescriptor,
202
- opType: operation.operationKind,
203
- strictAmbiguity: strictTargets,
204
- paragraphMetadataIndex: currentMetadataIndex,
205
- metadataIndices,
206
- onInfo: options.onInfo,
207
- onWarn: options.onWarn
208
- });
223
+ let resolved;
224
+ if (compiledBinding.dynamic) {
225
+ resolved = resolveTargetParagraph(xmlDoc, {
226
+ targetText: operation.target,
227
+ targetRef: operation.targetRef,
228
+ targetDescriptor: operation.targetDescriptor,
229
+ opType: operation.operationKind,
230
+ strictAmbiguity: strictTargets,
231
+ paragraphMetadataIndex: currentMetadataIndex,
232
+ metadataIndices,
233
+ onInfo: options.onInfo,
234
+ onWarn: options.onWarn
235
+ });
236
+ } else {
237
+ const bound = batchCompilation.registry.resolve(compiledBinding.sourceIds[0], xmlDoc);
238
+ if (bound.error) throw Object.assign(new Error(bound.error.message), bound.error);
239
+ resolved = {
240
+ paragraph: bound.paragraph,
241
+ resolvedBy: compiledBinding.resolvedBy,
242
+ warnings: compiledBinding.warnings
243
+ };
244
+ }
209
245
  const paragraph = resolved.paragraph;
210
246
  const metadata = targetMetadata(xmlDoc, paragraph, resolved.resolvedBy, operation.target, currentMetadataIndex, targetView);
211
247
  const paragraphText = metadata.resolvedTarget.text;
@@ -313,8 +349,10 @@ export function preflightOperations(documentXml, operations, author, options = {
313
349
  const allSame = authors.length > 0 && authors.every(a => a.trim().toLowerCase() === opAuthor);
314
350
  if (!allSame && existingPolicy === 'merge-same-author') {
315
351
  error = {
316
- code: 'EXISTING_REVISIONS',
317
- message: `Target paragraph contains tracked changes from another author (${authors.length ? authors.join(', ') : 'unattributed'}). Pass existingRevisions: "accept-all-first" or resolve revisions first.`
352
+ code: 'EXISTING_REVISIONS',
353
+ message: `Target paragraph contains tracked changes from another author (${authors.length ? authors.join(', ') : 'unattributed'}). Use existingRevisions: "slice-cross-author" for a surgical edit that preserves reviewer history; accepting or rejecting revisions requires separate authorization.`,
354
+ revisionAuthors: authors,
355
+ currentPolicy: existingPolicy
318
356
  };
319
357
  } else if (allSame) {
320
358
  const mergeCommentIds = getCommentIdsInParagraph(paragraph);
@@ -408,43 +446,24 @@ export function preflightOperations(documentXml, operations, author, options = {
408
446
  }
409
447
  }
410
448
 
411
- const conflicts = [];
412
- const byTarget = new Map();
413
- for (const result of results) {
414
- const targetIndex = result.resolvedTarget?.index;
415
- if (!targetIndex) continue;
416
- if (!byTarget.has(targetIndex)) byTarget.set(targetIndex, []);
417
- byTarget.get(targetIndex).push(result);
418
- }
419
-
420
- for (const [targetIndex, targetResults] of byTarget) {
421
- const redlines = targetResults.filter(result => ['redline', 'restore', 'rejected-insert'].includes(result.operationType));
422
- const highlights = targetResults.filter(result => result.operationType === 'highlight');
423
- const target = targetResults[0].resolvedTarget;
424
- if (redlines.length > 1) {
425
- conflicts.push(buildConflict(
426
- 'OVERLAPPING_TEXT_EDITS',
427
- `Multiple text edits target paragraph ${targetIndex}; later operations may use a stale anchor.`,
428
- redlines.map(result => result.index),
429
- target
430
- ));
431
- }
432
- if (redlines.length > 0 && highlights.length > 0) {
433
- conflicts.push(buildConflict(
434
- 'REVISION_ORDER_CONFLICT',
435
- `A text edit and highlight target paragraph ${targetIndex}; operation order can invalidate the target or existing-revision policy.`,
436
- [...redlines, ...highlights].map(result => result.index).sort((a, b) => a - b),
437
- target
438
- ));
439
- }
440
- }
449
+ const conflicts = batchCompilation.conflicts;
441
450
 
442
- const hasErrors = results.some(result => result.status === 'error');
443
- return {
444
- valid: !hasErrors && conflicts.length === 0,
445
- status: !hasErrors && conflicts.length === 0 ? 'ok' : 'error',
446
- results,
447
- conflicts,
451
+ const enrichedResults = results.map(result => result.error ? {
452
+ ...result,
453
+ error: normalizedError(result.error, {
454
+ operationIndex: result.index,
455
+ ...(sourceOperations[result.index - 1]?.operationId
456
+ ? { operationId: sourceOperations[result.index - 1].operationId }
457
+ : {})
458
+ })
459
+ } : result);
460
+ const enrichedConflicts = conflicts.map(conflict => normalizedError(conflict));
461
+ const hasErrors = enrichedResults.some(result => result.status === 'error');
462
+ return {
463
+ valid: !hasErrors && enrichedConflicts.length === 0,
464
+ status: !hasErrors && enrichedConflicts.length === 0 ? 'ok' : 'error',
465
+ results: enrichedResults,
466
+ conflicts: enrichedConflicts,
448
467
  authorsUsed: Array.from(authorsUsed),
449
468
  requiredArtifacts: {
450
469
  comments: commentsRequired,
@@ -10,9 +10,11 @@ export interface ParagraphTargetDescriptor {
10
10
  inTable?: boolean;
11
11
  fingerprint?: string;
12
12
  sourceFingerprint?: string;
13
+ /** Defaults to rejected for restore operations and accepted for all other operations. */
13
14
  revisionView?: 'accepted' | 'rejected';
14
15
  captureRef?: string;
15
16
  select?: string;
17
+ createdByOperation?: number;
16
18
  }
17
19
 
18
20
  export interface InsertionAffinity {
@@ -238,6 +240,17 @@ export interface DocumentOperationBatchResult {
238
240
  status?: RedlineStatus;
239
241
  error?: RedlineError;
240
242
  warnings?: string[];
243
+ conflicts?: OperationConflict[];
244
+ retryPlan?: MutationRetryPlan;
245
+ }
246
+
247
+ export interface MutationRetryPlan {
248
+ base: 'original' | 'output';
249
+ committedIndexes: number[];
250
+ failedIndexes: number[];
251
+ unattemptedIndexes: number[];
252
+ replayWholeBatch: boolean;
253
+ sameArgumentsSafe: false;
241
254
  }
242
255
 
243
256
  export interface OperationPreflightItemResult {
@@ -271,10 +284,13 @@ export interface OperationPreflightItemResult {
271
284
  }
272
285
 
273
286
  export interface OperationConflict {
274
- code: 'OVERLAPPING_TEXT_EDITS' | 'REVISION_ORDER_CONFLICT' | string;
287
+ code: 'OVERLAPPING_SOURCE_TARGETS' | 'OVERLAPPING_TEXT_EDITS' | 'REVISION_ORDER_CONFLICT' | 'CAPTURE_FANOUT_CONFLICT' | string;
275
288
  message: string;
276
289
  operationIndexes: number[];
277
290
  target: ResolvedDocumentTarget;
291
+ recoveryVersion?: number;
292
+ category?: string;
293
+ recovery?: RedlineError['recovery'];
278
294
  }
279
295
 
280
296
  export interface OperationPreflightResult {