@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
@@ -0,0 +1,174 @@
1
+ export const ERROR_RECOVERY_VERSION = 1;
2
+
3
+ const RULES = Object.freeze({
4
+ INVALID_OPERATION: ['request', 'request_fixable', 'change_request'],
5
+ INVALID_AGENT_REQUEST: ['request', 'request_fixable', 'change_request'],
6
+ INVALID_FILTER: ['request', 'request_fixable', 'change_request'],
7
+ INVALID_OPERATIONS_FILE: ['request', 'request_fixable', 'change_request'],
8
+ OPERATIONS_REQUIRED: ['request', 'request_fixable', 'change_request'],
9
+ UNKNOWN_OPTION: ['request', 'request_fixable', 'change_request'],
10
+ UNEXPECTED_ARGUMENT: ['request', 'request_fixable', 'change_request'],
11
+ INVALID_ACTION: ['request', 'request_fixable', 'change_request'],
12
+ INVALID_PROFILE: ['request', 'request_fixable', 'change_request'],
13
+ INVALID_REVISION_TOKEN: ['revision-check', 'request_fixable', 'change_request'],
14
+ BATCH_OPERATION_FAILED: ['batch-execution', 'request_fixable', 'inspect_failed_operations'],
15
+ REVISION_TOKEN_SCOPE_MISMATCH: ['revision-check', 'request_fixable', 'change_request'],
16
+ REVISION_MISMATCH: ['revision-check', 'target_refresh_required', 'reinspect'],
17
+ TARGET_NOT_FOUND: ['target-resolution', 'target_refresh_required', 'reinspect'],
18
+ SOURCE_TARGET_NOT_FOUND: ['target-resolution', 'target_refresh_required', 'reinspect'],
19
+ TARGET_TEXT_MISMATCH: ['target-resolution', 'target_refresh_required', 'reinspect'],
20
+ TARGET_FINGERPRINT_MISMATCH: ['target-resolution', 'target_refresh_required', 'reinspect'],
21
+ TARGET_INDEX_MISMATCH: ['target-resolution', 'target_refresh_required', 'reinspect'],
22
+ TARGET_OCCURRENCE_MISMATCH: ['target-resolution', 'candidate_selection_required', 'choose_candidate'],
23
+ STALE_TARGET_HANDLE: ['target-resolution', 'target_refresh_required', 'reinspect'],
24
+ TARGET_HANDLE_NOT_FOUND: ['target-resolution', 'target_refresh_required', 'reinspect'],
25
+ AMBIGUOUS_TARGET: ['target-resolution', 'candidate_selection_required', 'choose_candidate'],
26
+ TARGET_RANGE_INVALID: ['target-resolution', 'request_fixable', 'change_target_range'],
27
+ ANCHOR_NOT_FOUND: ['anchor-resolution', 'request_fixable', 'change_anchor'],
28
+ AMBIGUOUS_ANCHOR: ['anchor-resolution', 'candidate_selection_required', 'choose_candidate'],
29
+ PATCH_SOURCE_NOT_FOUND: ['patch-compilation', 'request_fixable', 'change_patch'],
30
+ AMBIGUOUS_PATCH_SOURCE: ['patch-compilation', 'candidate_selection_required', 'choose_occurrence'],
31
+ OVERLAPPING_PATCHES: ['patch-compilation', 'source_conflict', 'combine_patches'],
32
+ CONFLICTING_PATCHES: ['patch-compilation', 'source_conflict', 'combine_patches'],
33
+ PATCH_ROUNDTRIP_MISMATCH: ['patch-validation', 'request_fixable', 'reinspect_and_narrow'],
34
+ DIFF_TOKEN_LIMIT: ['patch-compilation', 'request_fixable', 'narrow_operation'],
35
+ STRUCTURED_CONTENT_INVALID: ['request', 'request_fixable', 'change_request'],
36
+ UNSUPPORTED_INSERTION_AFFINITY: ['request', 'request_fixable', 'change_request'],
37
+ EXISTING_REVISIONS: ['target-safety', 'policy_choice_required', 'set_option'],
38
+ COMMENTED_CONTENT_MERGE: ['target-safety', 'user_authorization_required', 'resolve_comments'],
39
+ COMMENTED_CONTENT_DELETE: ['target-safety', 'user_authorization_required', 'resolve_comments'],
40
+ OVERLAPPING_SOURCE_TARGETS: ['batch-compilation', 'source_conflict', 'consolidate_operations'],
41
+ OVERLAPPING_TEXT_EDITS: ['batch-compilation', 'source_conflict', 'consolidate_operations'],
42
+ REVISION_ORDER_CONFLICT: ['batch-compilation', 'source_conflict', 'split_or_consolidate_operations'],
43
+ TARGET_CONSUMED_BY_OPERATION: ['batch-execution', 'source_conflict', 'use_created_content_dependency'],
44
+ CAPTURE_FANOUT_CONFLICT: ['batch-compilation', 'source_conflict', 'split_or_chain_capture_consumers'],
45
+ DUPLICATE_CAPTURE_KEY: ['batch-compilation', 'request_fixable', 'rename_capture'],
46
+ CAPTURE_NOT_FOUND: ['batch-compilation', 'request_fixable', 'add_or_correct_capture'],
47
+ CAPTURE_DEPENDENCY_CYCLE: ['batch-compilation', 'source_conflict', 'replan_batch'],
48
+ AMBIGUOUS_CAPTURE_SELECTION: ['batch-compilation', 'candidate_selection_required', 'choose_candidate'],
49
+ CAPTURE_STALE: ['batch-execution', 'source_conflict', 'replan_batch'],
50
+ GENERATED_OOXML_INVALID: ['validation', 'library_or_builder_failure', 'report_library_failure'],
51
+ DOCUMENT_SERIALIZATION_FAILED: ['serialization', 'library_or_builder_failure', 'report_library_failure'],
52
+ PACKAGE_OPERATION_FAILED: ['package', 'library_or_builder_failure', 'report_library_failure'],
53
+ PACKAGE_VALIDATION: ['package-validation', 'library_or_builder_failure', 'report_library_failure'],
54
+ MUTATION_RECEIPT_MISMATCH: ['receipt-validation', 'library_or_builder_failure', 'report_library_failure'],
55
+ FOREIGN_PARAGRAPH_MARK_DELETION: ['target-safety', 'policy_choice_required', 'use_restore_or_leave_deleted'],
56
+ RESTORATION_STATE_REQUIRED: ['target-safety', 'target_refresh_required', 'reinspect'],
57
+ RESTORATION_COUNT_MISMATCH: ['request', 'request_fixable', 'change_request'],
58
+ REJECTED_INSERTION_STATE_REQUIRED: ['target-safety', 'target_refresh_required', 'reinspect'],
59
+ UNSAFE_REVISION_NESTING: ['target-safety', 'manual_document_resolution', 'manual_resolution'],
60
+ UNSAFE_REVISION_BOUNDARY: ['target-safety', 'manual_document_resolution', 'manual_resolution'],
61
+ UNSAFE_PARAGRAPH_BOUNDARY: ['target-safety', 'manual_document_resolution', 'manual_resolution'],
62
+ UNSAFE_DELETED_TABLE_ROW: ['target-safety', 'manual_document_resolution', 'manual_resolution'],
63
+ UNSUPPORTED_MOVE_REVISION: ['target-safety', 'manual_document_resolution', 'manual_resolution'],
64
+ SECTION_BREAK_PARAGRAPH: ['target-safety', 'manual_document_resolution', 'manual_resolution'],
65
+ UNSAFE_PARAGRAPH_PLACEMENT: ['target-safety', 'manual_document_resolution', 'manual_resolution'],
66
+ UNSUPPORTED_REVISION_VIEW_MUTATION: ['target-safety', 'manual_document_resolution', 'manual_resolution']
67
+ });
68
+
69
+ function issueSummary(error) {
70
+ const issues = Array.isArray(error?.generatedIssues)
71
+ ? error.generatedIssues
72
+ : (Array.isArray(error?.issues) ? error.issues : null);
73
+ if (!issues) return null;
74
+ const byCode = new Map();
75
+ for (const issue of issues) {
76
+ const code = issue?.code || 'UNKNOWN';
77
+ byCode.set(code, (byCode.get(code) || 0) + 1);
78
+ }
79
+ return {
80
+ total: issues.length,
81
+ byCode: Array.from(byCode, ([code, count]) => ({ code, count }))
82
+ };
83
+ }
84
+
85
+ function ruleFor(code) {
86
+ return RULES[code] || ['operation', 'manual_document_resolution', 'inspect_error'];
87
+ }
88
+
89
+ /** Add stable machine recovery metadata while preserving code-specific details. */
90
+ export function normalizeErrorWithRecovery(error, context = {}) {
91
+ const source = error && typeof error === 'object' ? error : {};
92
+ const code = typeof source.code === 'string' && source.code ? source.code : 'OPERATION_ERROR';
93
+ const [defaultStage, category, action] = ruleFor(code);
94
+ const details = {};
95
+ for (const [key, value] of Object.entries(source)) {
96
+ if (key === 'name' || key === 'stack' || key === 'message' || key === 'code') continue;
97
+ details[key] = value;
98
+ }
99
+ const requiresAuthorization = category === 'user_authorization_required';
100
+ const requiresReinspection = category === 'target_refresh_required'
101
+ || code === 'PATCH_ROUNDTRIP_MISMATCH';
102
+ const recovery = {
103
+ action,
104
+ sameArgumentsSafe: false,
105
+ requiresReinspection,
106
+ requiresUserAuthorization: requiresAuthorization,
107
+ ...(code === 'EXISTING_REVISIONS' ? {
108
+ field: 'existingRevisions',
109
+ recommendedValue: 'slice-cross-author'
110
+ } : {}),
111
+ ...(source.recovery && typeof source.recovery === 'object' ? source.recovery : {})
112
+ };
113
+ const summary = issueSummary(source);
114
+ const derivedContext = {
115
+ ...context,
116
+ ...(Array.isArray(source.revisionAuthors) ? { revisionAuthors: source.revisionAuthors } : {}),
117
+ ...(source.currentPolicy ? { currentPolicy: source.currentPolicy } : {})
118
+ };
119
+ if (
120
+ ['TARGET_TEXT_MISMATCH', 'TARGET_FINGERPRINT_MISMATCH'].includes(code)
121
+ && Array.isArray(source.candidates)
122
+ && source.candidates.length === 1
123
+ ) {
124
+ derivedContext.currentTarget = source.candidates[0];
125
+ derivedContext.requiresRecomposeModified = true;
126
+ }
127
+ return {
128
+ recoveryVersion: ERROR_RECOVERY_VERSION,
129
+ code,
130
+ message: source.message || String(error),
131
+ stage: source.stage || defaultStage,
132
+ category: source.category || category,
133
+ ...details,
134
+ ...(Object.keys(derivedContext).length > 0 || source.context ? {
135
+ context: { ...(source.context || {}), ...derivedContext }
136
+ } : {}),
137
+ ...(summary ? { issueSummary: summary } : {}),
138
+ recovery
139
+ };
140
+ }
141
+
142
+ export function createRetryPlan({
143
+ atomic = false,
144
+ rolledBack = false,
145
+ results = [],
146
+ receipts = [],
147
+ operationCount = null
148
+ } = {}) {
149
+ const count = Number.isInteger(operationCount)
150
+ ? operationCount
151
+ : Math.max(results.length, receipts.length);
152
+ const attempted = new Set(results.map(result => result?.index).filter(Number.isInteger));
153
+ const failedIndexes = results
154
+ .filter(result => result?.status === 'error')
155
+ .map(result => result.index)
156
+ .filter(Number.isInteger);
157
+ const committedIndexes = receipts
158
+ .filter(receipt => receipt?.committed === true)
159
+ .map(receipt => receipt.operationIndex)
160
+ .filter(Number.isInteger);
161
+ const unattemptedIndexes = [];
162
+ for (let index = 1; index <= count; index += 1) {
163
+ if (!attempted.has(index)) unattemptedIndexes.push(index);
164
+ }
165
+ const base = rolledBack || atomic || committedIndexes.length === 0 ? 'original' : 'output';
166
+ return {
167
+ base,
168
+ committedIndexes,
169
+ failedIndexes,
170
+ unattemptedIndexes,
171
+ replayWholeBatch: base === 'original',
172
+ sameArgumentsSafe: false
173
+ };
174
+ }
@@ -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
+ }