@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
@@ -1,5 +1,5 @@
1
1
  import { getDefaultAuthor } from '../adapters/config.js';
2
- import {
2
+ import {
3
3
  normalizeDocumentOperation,
4
4
  resolveDocumentOperationAuthor,
5
5
  validateDocumentOperation
@@ -15,6 +15,7 @@ import {
15
15
  applyFormattingToParagraphByExactText,
16
16
  applyHighlightToParagraphByExactText,
17
17
  applyParagraphFormatToParagraphByExactText,
18
+ insertIntoRejectedDeletedText,
18
19
  restoreDeletedParagraphByExactText,
19
20
  applyToParagraphByExactText
20
21
  } from './document-operation-mutations.js';
@@ -26,22 +27,25 @@ import {
26
27
  import {
27
28
  createEmptyReceipt,
28
29
  reconcileReceiptsAgainstOutput
29
- } from './receipt-collector.js';
30
-
31
- export function normalizeOperationError(error) {
32
- return {
33
- code: typeof error?.code === 'string' && error.code ? error.code : 'OPERATION_ERROR',
34
- message: error?.message || String(error),
35
- ...(Array.isArray(error?.candidates) ? { candidates: error.candidates } : {})
36
- };
37
- }
30
+ } from './receipt-collector.js';
31
+ import { validateRedlineOoxml } from '../core/redline-validation.js';
32
+ import { subtractValidationIssueMultiset, validationErrors } from '../core/validation-delta.js';
33
+ import { normalizeErrorWithRecovery } from './error-recovery.js';
34
+
35
+ export function normalizeOperationError(error, context = {}) {
36
+ return normalizeErrorWithRecovery(error, context);
37
+ }
38
38
 
39
39
  /**
40
40
  * Validates and dispatches one structured operation against full document XML.
41
41
  * Result metadata is assembled here so every mutation path exposes the same
42
42
  * */
43
- export async function applyOperationToDocumentXml(documentXml, op, author, runtimeContext = null, options = {}) {
44
- const operationIndex = typeof options._operationIndex === 'number' ? options._operationIndex : 1;
43
+ export async function applyOperationToDocumentXml(documentXml, op, author, runtimeContext = null, options = {}) {
44
+ const operationIndex = typeof options._operationIndex === 'number' ? options._operationIndex : 1;
45
+ const errorContext = {
46
+ operationIndex,
47
+ ...(typeof op?.operationId === 'string' ? { operationId: op.operationId } : {})
48
+ };
45
49
  const validation = validateDocumentOperation(op);
46
50
  if (!validation.valid) {
47
51
  const authorUsed = resolveDocumentOperationAuthor(op, author, getDefaultAuthor());
@@ -49,7 +53,7 @@ export async function applyOperationToDocumentXml(documentXml, op, author, runti
49
53
  documentXml,
50
54
  hasChanges: false,
51
55
  status: 'error',
52
- error: validation.error,
56
+ error: normalizeOperationError(validation.error, errorContext),
53
57
  operationType: normalizeDocumentOperation(op).operationKind,
54
58
  authorUsed,
55
59
  receipt: createEmptyReceipt(operationIndex, op?.operationId, authorUsed, 'refused')
@@ -61,6 +65,8 @@ export async function applyOperationToDocumentXml(documentXml, op, author, runti
61
65
 
62
66
  if (
63
67
  operation.operationKind !== 'comment_reply'
68
+ && operation.operationKind !== 'rejected-insert'
69
+ && operation.operationKind !== 'restore'
64
70
  && (
65
71
  operation.targetDescriptor?.revisionView === 'rejected'
66
72
  || operation.targetEndDescriptor?.revisionView === 'rejected'
@@ -70,10 +76,10 @@ export async function applyOperationToDocumentXml(documentXml, op, author, runti
70
76
  documentXml,
71
77
  hasChanges: false,
72
78
  status: 'error',
73
- error: {
74
- code: 'UNSUPPORTED_REVISION_VIEW_MUTATION',
75
- message: 'Targeting rejected revision view for mutation is not supported yet.'
76
- },
79
+ error: normalizeOperationError({
80
+ code: 'UNSUPPORTED_REVISION_VIEW_MUTATION',
81
+ message: 'Targeting rejected revision view for mutation is not supported yet.'
82
+ }, errorContext),
77
83
  operationType: operation.operationKind,
78
84
  authorUsed,
79
85
  receipt: createEmptyReceipt(operationIndex, operation.operationId, authorUsed, 'refused')
@@ -87,10 +93,10 @@ export async function applyOperationToDocumentXml(documentXml, op, author, runti
87
93
  documentXml,
88
94
  hasChanges: false,
89
95
  status: 'error',
90
- error: {
91
- code: tokenValidation.error?.code || 'INVALID_REVISION_TOKEN',
92
- message: tokenValidation.error?.message || 'Invalid revision token.'
93
- },
96
+ error: normalizeOperationError({
97
+ code: tokenValidation.error?.code || 'INVALID_REVISION_TOKEN',
98
+ message: tokenValidation.error?.message || 'Invalid revision token.'
99
+ }, errorContext),
94
100
  operationType: operation.operationKind,
95
101
  authorUsed,
96
102
  receipt: createEmptyReceipt(operationIndex, operation.operationId, authorUsed, 'refused')
@@ -101,10 +107,10 @@ export async function applyOperationToDocumentXml(documentXml, op, author, runti
101
107
  documentXml,
102
108
  hasChanges: false,
103
109
  status: 'error',
104
- error: {
105
- code: 'REVISION_TOKEN_SCOPE_MISMATCH',
106
- message: `Revision token scope mismatch: expected 'document-parts', got '${options.expectedRevision.scope}'.`
107
- },
110
+ error: normalizeOperationError({
111
+ code: 'REVISION_TOKEN_SCOPE_MISMATCH',
112
+ message: `Revision token scope mismatch: expected 'document-parts', got '${options.expectedRevision.scope}'.`
113
+ }, errorContext),
108
114
  operationType: operation.operationKind,
109
115
  authorUsed,
110
116
  receipt: createEmptyReceipt(operationIndex, operation.operationId, authorUsed, 'refused')
@@ -122,10 +128,12 @@ export async function applyOperationToDocumentXml(documentXml, op, author, runti
122
128
  documentXml,
123
129
  hasChanges: false,
124
130
  status: 'error',
125
- error: {
126
- code: 'REVISION_MISMATCH',
127
- message: `Document revision mismatch: expected '${options.expectedRevision.value}', current is '${currentToken.value}'.`
128
- },
131
+ error: normalizeOperationError({
132
+ code: 'REVISION_MISMATCH',
133
+ message: `Document revision mismatch: expected '${options.expectedRevision.value}', current is '${currentToken.value}'.`,
134
+ expectedRevision: options.expectedRevision,
135
+ currentRevision: currentToken
136
+ }, errorContext),
129
137
  operationType: operation.operationKind,
130
138
  authorUsed,
131
139
  receipt: createEmptyReceipt(operationIndex, operation.operationId, authorUsed, 'refused')
@@ -141,7 +149,7 @@ export async function applyOperationToDocumentXml(documentXml, op, author, runti
141
149
  documentXml,
142
150
  hasChanges: false,
143
151
  status: 'error',
144
- error: session.parseResult.error,
152
+ error: normalizeOperationError(session.parseResult.error, errorContext),
145
153
  warnings: session.parseResult.warnings,
146
154
  operationType: operation.operationKind,
147
155
  authorUsed,
@@ -156,7 +164,9 @@ export async function applyOperationToDocumentXml(documentXml, op, author, runti
156
164
  operation.operationId,
157
165
  authorUsed
158
166
  );
159
- const operationWarnings = [];
167
+ const operationWarnings = Array.isArray(operation._compiledWarnings)
168
+ ? [...operation._compiledWarnings]
169
+ : [];
160
170
  const operationOptions = {
161
171
  ...options,
162
172
  ...(typeof operation.generateRedlines === 'boolean' ? { generateRedlines: operation.generateRedlines } : {}),
@@ -168,6 +178,10 @@ export async function applyOperationToDocumentXml(documentXml, op, author, runti
168
178
  ...(operation.formattingRevisionPolicy ? { formattingRevisionPolicy: operation.formattingRevisionPolicy } : {}),
169
179
  targetDescriptor: operation.targetDescriptor,
170
180
  targetEndDescriptor: operation.targetEndDescriptor,
181
+ _compiledSourceId: operation._compiledSourceId || null,
182
+ _compiledSourceEndId: operation._compiledSourceEndId || null,
183
+ _compiledResolvedBy: operation._compiledResolvedBy || null,
184
+ _sourceTargetRegistry: session.sourceTargetRegistry || null,
171
185
  _resolutionCapture: resolutionCapture,
172
186
  _revisionIdAllocator: session.revisionIdAllocator,
173
187
  _documentOperationSession: session,
@@ -250,6 +264,17 @@ export async function applyOperationToDocumentXml(documentXml, op, author, runti
250
264
  runtimeContext,
251
265
  operationOptions
252
266
  );
267
+ } else if (operation.operationKind === 'rejected-insert') {
268
+ result = await insertIntoRejectedDeletedText(
269
+ documentXml,
270
+ operation.target,
271
+ operation.anchor,
272
+ operation.modified,
273
+ authorUsed,
274
+ operation.targetRef,
275
+ runtimeContext,
276
+ operationOptions
277
+ );
253
278
  } else if (operation.operationKind === 'restore') {
254
279
  result = await restoreDeletedParagraphByExactText(
255
280
  documentXml,
@@ -311,8 +336,45 @@ export async function applyOperationToDocumentXml(documentXml, op, author, runti
311
336
  operationReceipt.warnings.push(String(w));
312
337
  }
313
338
  }
314
- } else {
315
- session.markMutationCommitted(operation.operationKind !== 'comment_reply');
339
+ } else {
340
+ const beforeValidation = validateRedlineOoxml(savepoint.document);
341
+ const afterValidation = validateRedlineOoxml(session.document);
342
+ const generatedIssues = subtractValidationIssueMultiset(afterValidation.issues, beforeValidation.issues);
343
+ const generatedErrors = validationErrors(generatedIssues);
344
+ if (generatedErrors.length > 0) {
345
+ session.restoreSavepoint(savepoint);
346
+ operationReceipt = createEmptyReceipt(
347
+ operationIndex,
348
+ operation.operationId,
349
+ authorUsed,
350
+ 'refused'
351
+ );
352
+ const codes = [...new Set(generatedErrors.map(issue => issue.code))].join(', ');
353
+ return {
354
+ documentXml,
355
+ hasChanges: false,
356
+ status: 'error',
357
+ error: normalizeOperationError({
358
+ code: 'GENERATED_OOXML_INVALID',
359
+ stage: 'validation',
360
+ message: `Operation introduced invalid OOXML (${codes}).`,
361
+ generatedIssues: generatedErrors
362
+ }, errorContext),
363
+ operationType: operation.operationKind,
364
+ authorUsed,
365
+ receipt: operationReceipt,
366
+ ...resolutionCapture
367
+ };
368
+ }
369
+ session.markMutationCommitted(operation.operationKind !== 'comment_reply');
370
+ if (operation._compiledSourceId && session.sourceTargetRegistry) {
371
+ session.sourceTargetRegistry.commitMutation(
372
+ operation._compiledSourceId,
373
+ operationOptions._mutationRemovedNodes,
374
+ operationOptions._mutationLiveNodes,
375
+ operationIndex
376
+ );
377
+ }
316
378
  if (operation.captureKey && session.captureTable) {
317
379
  session.captureTable.set(
318
380
  operation.captureKey,
@@ -349,7 +411,7 @@ export async function applyOperationToDocumentXml(documentXml, op, author, runti
349
411
  documentXml,
350
412
  hasChanges: false,
351
413
  status: 'error',
352
- error: reconciliation.error,
414
+ error: normalizeOperationError(reconciliation.error, errorContext),
353
415
  warnings: [reconciliation.error.message],
354
416
  operationType: operation.operationKind,
355
417
  authorUsed,
@@ -359,7 +421,8 @@ export async function applyOperationToDocumentXml(documentXml, op, author, runti
359
421
  }
360
422
  }
361
423
  }
362
- return {
424
+ if (result?.error) result.error = normalizeOperationError(result.error, errorContext);
425
+ return {
363
426
  ...result,
364
427
  operationType: operation.operationKind,
365
428
  authorUsed,
@@ -368,7 +431,7 @@ export async function applyOperationToDocumentXml(documentXml, op, author, runti
368
431
  };
369
432
  } catch (error) {
370
433
  session.restoreSavepoint(savepoint);
371
- const normalizedError = normalizeOperationError(error);
434
+ const normalizedError = normalizeOperationError(error, errorContext);
372
435
  const operationReceipt = createEmptyReceipt(
373
436
  operationIndex,
374
437
  operation.operationId,
@@ -43,6 +43,7 @@ function nonEmptyString(value) {
43
43
 
44
44
  export function getCanonicalOperationType(operation) {
45
45
  const type = operation?.type;
46
+ if (type === 'insert' && operation?.target?.revisionView === 'rejected') return 'rejected-insert';
46
47
  if (type === 'restore') return 'restore';
47
48
  if (type === 'comment' || type === 'comment_reply' || type === 'highlight') return type;
48
49
  if (type === 'paragraph-format') return 'paragraph-format';
@@ -50,7 +51,8 @@ export function getCanonicalOperationType(operation) {
50
51
  return 'redline';
51
52
  }
52
53
 
53
- export function normalizeTargetDescriptor(target, legacyTargetRef = null) {
54
+ export function normalizeTargetDescriptor(target, legacyTargetRef = null, defaultRevisionView = 'accepted') {
55
+ const fallbackRevisionView = defaultRevisionView === 'rejected' ? 'rejected' : 'accepted';
54
56
  if (!isRecord(target)) {
55
57
  return {
56
58
  text: typeof target === 'string' ? target : '',
@@ -59,7 +61,7 @@ export function normalizeTargetDescriptor(target, legacyTargetRef = null) {
59
61
  occurrence: null,
60
62
  inTable: null,
61
63
  fingerprint: null,
62
- revisionView: 'accepted'
64
+ revisionView: fallbackRevisionView
63
65
  };
64
66
  }
65
67
 
@@ -76,7 +78,9 @@ export function normalizeTargetDescriptor(target, legacyTargetRef = null) {
76
78
  fingerprint: nonEmptyString(target.fingerprint)
77
79
  ? target.fingerprint.trim()
78
80
  : (nonEmptyString(target.sourceFingerprint) ? target.sourceFingerprint.trim() : null),
79
- revisionView: target.revisionView === 'rejected' ? 'rejected' : 'accepted',
81
+ revisionView: target.revisionView === 'rejected'
82
+ ? 'rejected'
83
+ : (target.revisionView === 'accepted' ? 'accepted' : fallbackRevisionView),
80
84
  captureRef: nonEmptyString(target.captureRef) ? target.captureRef.trim() : null,
81
85
  select: typeof target.select === 'string' ? target.select : null
82
86
  };
@@ -84,17 +88,24 @@ export function normalizeTargetDescriptor(target, legacyTargetRef = null) {
84
88
 
85
89
  export function normalizeDocumentOperation(operation) {
86
90
  const source = isRecord(operation) ? operation : {};
87
- const targetDescriptor = normalizeTargetDescriptor(source.target, source.targetRef);
91
+ const kind = getCanonicalOperationType(source);
92
+ const defaultRevisionView = kind === 'restore' ? 'rejected' : 'accepted';
93
+ const targetDescriptor = normalizeTargetDescriptor(source.target, source.targetRef, defaultRevisionView);
88
94
  const targetEndDescriptor = source.targetEnd != null
89
- ? normalizeTargetDescriptor(source.targetEnd, source.targetEndRef)
95
+ ? normalizeTargetDescriptor(source.targetEnd, source.targetEndRef, defaultRevisionView)
90
96
  : null;
91
- const kind = getCanonicalOperationType(source);
92
97
 
93
98
  return {
94
99
  ...source,
95
100
  operationId: nonEmptyString(source.operationId) ? source.operationId.trim() : null,
96
101
  captureKey: nonEmptyString(source.captureKey) ? source.captureKey.trim() : null,
97
102
  operationKind: kind,
103
+ anchor: isRecord(source.anchor) ? {
104
+ exactText: typeof source.anchor.exactText === 'string' ? source.anchor.exactText : '',
105
+ occurrence: Number.isInteger(source.anchor.occurrence) && source.anchor.occurrence > 0 ? source.anchor.occurrence : 1,
106
+ occurrenceExplicit: Number.isInteger(source.anchor.occurrence) && source.anchor.occurrence > 0,
107
+ offset: Number.isInteger(source.anchor.offset) ? source.anchor.offset : null
108
+ } : null,
98
109
  targetDescriptor,
99
110
  targetEndDescriptor,
100
111
  target: targetDescriptor.text,
@@ -211,6 +222,39 @@ export function validateDocumentOperation(operation) {
211
222
  };
212
223
  }
213
224
 
225
+ if (normalized.operationKind === 'rejected-insert') {
226
+ if (!nonEmptyString(normalized.modified)) {
227
+ return {
228
+ valid: false,
229
+ error: { code: 'INVALID_OPERATION', message: 'Rejected-view insert operations require non-empty string "modified" text.' }
230
+ };
231
+ }
232
+ if (
233
+ !normalized.anchor
234
+ || !nonEmptyString(normalized.anchor.exactText)
235
+ || !Number.isInteger(normalized.anchor.offset)
236
+ || normalized.anchor.offset < 0
237
+ || normalized.anchor.offset > normalized.anchor.exactText.length
238
+ ) {
239
+ return {
240
+ valid: false,
241
+ error: {
242
+ code: 'INVALID_OPERATION',
243
+ message: 'Rejected-view insert operations require anchor.exactText, a positive occurrence, and an offset within the anchor text.'
244
+ }
245
+ };
246
+ }
247
+ if (normalized.existingRevisions !== 'slice-cross-author') {
248
+ return {
249
+ valid: false,
250
+ error: {
251
+ code: 'INVALID_OPERATION',
252
+ message: 'Rejected-view insert operations require existingRevisions: "slice-cross-author".'
253
+ }
254
+ };
255
+ }
256
+ }
257
+
214
258
  if (normalized.operationKind === 'restore') {
215
259
  const validSingle = nonEmptyString(normalized.modified);
216
260
  const validRange = Array.isArray(normalized.modified)