@ansonlai/docx-redline-js 0.8.3 → 0.9.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 (71) hide show
  1. package/ARCHITECTURE.md +221 -12
  2. package/CHANGELOG.md +803 -586
  3. package/README.md +1018 -901
  4. package/core/current-properties.js +136 -0
  5. package/core/paragraph-text.js +211 -209
  6. package/core/types.js +9 -3
  7. package/core/word-revision-semantics.js +256 -0
  8. package/dist/docx-redline-js.esm.js +4551 -967
  9. package/dist/docx-redline-js.esm.js.map +4 -4
  10. package/dist/docx-redline-js.esm.min.js +112 -106
  11. package/dist/docx-redline-js.esm.min.js.map +4 -4
  12. package/dist/docx-redline.bundle.cjs +4690 -1106
  13. package/dist/docx-redline.bundle.cjs.map +4 -4
  14. package/dist/docx-redline.bundle.js +4690 -1106
  15. package/dist/docx-redline.bundle.js.map +4 -4
  16. package/docs/AGENT_FAST_START.md +2 -0
  17. package/docs/AGENT_KNOWLEDGE_BASE.md +23 -4
  18. package/docs/SKILL_AUTHORING.md +165 -150
  19. package/docs/TESTING.md +1142 -1059
  20. package/docs/schemas/document-operations.schema.json +221 -168
  21. package/document/docx-document.js +1022 -1010
  22. package/engine/oxml-engine.js +567 -560
  23. package/engine/paragraph-mark-revisions.js +292 -0
  24. package/engine/reconstruction-mapper.js +312 -311
  25. package/engine/reconstruction-mode.js +183 -183
  26. package/engine/reconstruction-writer.js +67 -10
  27. package/engine/rpr-helpers.js +42 -0
  28. package/engine/run-builders.js +0 -73
  29. package/engine/surgical-diff-application.js +604 -605
  30. package/engine/table-mode.js +63 -63
  31. package/index.d.ts +51 -1
  32. package/index.js +47 -3
  33. package/node/cli-help.js +1 -0
  34. package/node/cli.js +992 -974
  35. package/orchestration/list-structural-fallback.js +67 -9
  36. package/package.json +1 -1
  37. package/pipeline/diff-engine.js +130 -1
  38. package/pipeline/ingestion-export.js +227 -243
  39. package/pipeline/ingestion-paragraph.js +93 -98
  40. package/pipeline/list-generation.js +415 -419
  41. package/pipeline/pipeline.js +206 -204
  42. package/pipeline/serialization.js +111 -94
  43. package/scripts/export-validation-fixtures.mjs +19 -6
  44. package/scripts/generate-word-revision-semantics-matrix.mjs +134 -0
  45. package/scripts/lib/word-coverage-catalogue.mjs +7 -2
  46. package/scripts/render-multilevel-bullet-visual.ps1 +2 -2
  47. package/scripts/word-com-corpus-visual-suite.ps1 +2 -2
  48. package/scripts/word-com-differential.ps1 +443 -335
  49. package/scripts/word-com-visual-suite.ps1 +2 -2
  50. package/scripts/word-revision-semantics-matrix.ps1 +108 -0
  51. package/services/batch-operation-orchestrator.js +26 -0
  52. package/services/capture-engine.js +228 -226
  53. package/services/document-operation-applier.js +215 -0
  54. package/services/document-operation-contract.js +628 -537
  55. package/services/document-operation-mutations.js +789 -46
  56. package/services/error-recovery.js +17 -0
  57. package/services/list-conversion-diagnostics.js +296 -0
  58. package/services/list-format-change.js +586 -0
  59. package/services/list-format-mutation.js +416 -0
  60. package/services/list-format.js +305 -0
  61. package/services/numbering-allocation.js +642 -0
  62. package/services/numbering-helpers.js +354 -524
  63. package/services/operation-batch-compiler.js +612 -436
  64. package/services/operation-heuristics.js +197 -173
  65. package/services/operation-preflight.js +1 -0
  66. package/services/receipt-collector.js +288 -288
  67. package/services/revision-comment-management.js +634 -594
  68. package/services/revision-fidelity-verifier.js +498 -0
  69. package/services/standalone-docx-plumbing.js +627 -631
  70. package/services/standalone-operation-runner.d.ts +606 -427
  71. package/services/table-reconciliation.js +103 -49
@@ -1,6 +1,7 @@
1
1
  import { getDefaultAuthor } from '../adapters/config.js';
2
2
  import {
3
3
  normalizeDocumentOperation,
4
+ normalizeTargetDescriptor,
4
5
  resolveDocumentOperationAuthor,
5
6
  validateDocumentOperation
6
7
  } from './document-operation-contract.js';
@@ -16,8 +17,10 @@ import {
16
17
  applyFormattingToParagraphByExactText,
17
18
  applyHighlightToParagraphByExactText,
18
19
  applyParagraphFormatToParagraphByExactText,
20
+ applyListFormatToParagraphByExactText,
19
21
  insertIntoRejectedDeletedText,
20
22
  restoreDeletedParagraphByExactText,
23
+ resolveOperationSourceRange,
21
24
  applyToParagraphByExactText
22
25
  } from './document-operation-mutations.js';
23
26
  import { applyCommentReplyToParts } from './comment-replies.js';
@@ -35,6 +38,18 @@ import { subtractValidationIssueMultiset, validationErrors } from '../core/valid
35
38
  import { normalizeErrorWithRecovery } from './error-recovery.js';
36
39
  import { extractCanonicalParagraphText } from '../core/paragraph-text.js';
37
40
  import { buildLocalizedReplacementChange } from './localized-replacement-compiler.js';
41
+ import {
42
+ buildRevisionFidelityError,
43
+ createFidelityViews,
44
+ resolveRevisionFidelityMode,
45
+ revisionFidelityBaselineView,
46
+ isPlainTextIntent,
47
+ verifyAcceptIntent,
48
+ verifyNumberingReferences,
49
+ verifyRevisionFidelity,
50
+ verifyNoCreatedRevisions,
51
+ verifyUntrackedLocality
52
+ } from './revision-fidelity-verifier.js';
38
53
 
39
54
  export function normalizeOperationError(error, context = {}) {
40
55
  return normalizeErrorWithRecovery(error, context);
@@ -199,9 +214,36 @@ export async function applyOperationToDocumentXml(documentXml, op, author, runti
199
214
  operation = compiledValidation.operation;
200
215
  }
201
216
 
217
+ if (Array.isArray(operation.replacements) && !operation._localizedReplacementCompilation) {
218
+ // Uncompiled replacements would otherwise reach the mutation with no "modified" text and
219
+ // delete the paragraph; never apply them without their compiled accepted-text intent.
220
+ return {
221
+ documentXml,
222
+ hasChanges: false,
223
+ status: 'error',
224
+ error: normalizeOperationError({
225
+ code: 'PATCH_ROUNDTRIP_MISMATCH',
226
+ stage: 'validation',
227
+ message: 'Localized replacements were not compiled against a batch-start paragraph target, so they were refused instead of applied.'
228
+ }, errorContext),
229
+ operationType: operation.operationKind,
230
+ authorUsed,
231
+ receipt: createEmptyReceipt(operationIndex, operation.operationId, authorUsed, 'refused')
232
+ };
233
+ }
234
+
202
235
  const resolutionCapture = {};
203
236
  let localizedChange = null;
204
237
  const savepoint = session.createSavepoint();
238
+ // The previous verified operation's Reject All projection is this operation's baseline
239
+ // while the live DOM is the one it was computed on (a restored savepoint swaps the DOM).
240
+ // The memo is consumed here: any path that does not store a fresh one (an unverified commit, an
241
+ // early return) leaves it empty, so a stale entry can never describe a mutated DOM. The before-state
242
+ // entry is re-keyed to the savepoint clone, which is the live DOM again after a refusal restores it.
243
+ const priorViews = session._fidelityViews?.document === session.document ? session._fidelityViews : null;
244
+ session._fidelityViews = null;
245
+ const beforeViews = { ...(priorViews || createFidelityViews(null)), document: savepoint.document };
246
+ const afterViews = createFidelityViews(session.document);
205
247
  session.receiptCollector?.beginOperation(
206
248
  operationIndex,
207
249
  operation.operationId,
@@ -216,13 +258,22 @@ export async function applyOperationToDocumentXml(documentXml, op, author, runti
216
258
  ...(operation.existingRevisions ? { existingRevisions: operation.existingRevisions } : {}),
217
259
  structuredContent: typeof operation.structuredContent === 'boolean' ? operation.structuredContent : (options.structuredContent !== false),
218
260
  explicitStructuredContent: operation.structuredContent === true,
261
+ ...(operation._localizedReplacementCompilation && operation.structuredContent !== true ? { literalTextEdit: true } : {}),
219
262
  pairReplacements: typeof operation.pairReplacements === 'boolean' ? operation.pairReplacements : (options.pairReplacements !== false),
220
263
  ...(operation.insertionAffinity ? { insertionAffinity: operation.insertionAffinity } : {}),
264
+ ...(operation.listMarkers ? { listMarkers: operation.listMarkers } : {}),
221
265
  ...(operation.formattingRevisionPolicy ? { formattingRevisionPolicy: operation.formattingRevisionPolicy } : {}),
266
+ ...(operation.operationKind === 'format' && Number.isInteger(operation.textOccurrence) ? { textOccurrence: operation.textOccurrence } : {}),
222
267
  targetDescriptor: operation.targetDescriptor,
223
268
  targetEndDescriptor: operation.targetEndDescriptor,
224
269
  _compiledSourceId: operation._compiledSourceId || null,
225
270
  _compiledSourceEndId: operation._compiledSourceEndId || null,
271
+ ...(operation.operationKind === 'list-format' && operation.list?.continueFrom && typeof operation.list.continueFrom === 'object'
272
+ ? {
273
+ continueFromDescriptor: normalizeTargetDescriptor(operation.list.continueFrom, null, 'accepted'),
274
+ _compiledContinueFromId: operation._compiledContinueFromId || null
275
+ }
276
+ : {}),
226
277
  _compiledResolvedBy: operation._compiledResolvedBy || null,
227
278
  _sourceTargetRegistry: session.sourceTargetRegistry || null,
228
279
  _resolutionCapture: resolutionCapture,
@@ -239,6 +290,23 @@ export async function applyOperationToDocumentXml(documentXml, op, author, runti
239
290
  }
240
291
  };
241
292
 
293
+ // Resolve the target range before mutating, for the revision fidelity verifier: the exact accepted scope of an
294
+ // accept-first range target, and the target of a plain-text redline's ACCEPT_INTENT check.
295
+ const isRangeTarget = !!(operation.targetEndDescriptor || operation.targetEndRef);
296
+ const earlyFidelityMode = resolveRevisionFidelityMode(options.verifyRevisionFidelity);
297
+ const acceptFirstBaseline = revisionFidelityBaselineView(operationOptions.existingRevisions) === 'accepted';
298
+ const declaresAcceptIntent = operation.operationKind === 'redline'
299
+ && operationOptions.generateRedlines !== false
300
+ && !operation._localizedReplacementCompilation
301
+ && operation.structuredContent !== true
302
+ && isPlainTextIntent(operation.modified)
303
+ && !/\r?\n/.test(String(operation.targetDescriptor?.exactText ?? operation.targetDescriptor?.text ?? (typeof operation.target === 'string' ? operation.target : '')));
304
+ const sourceRange = earlyFidelityMode !== 'off' && session.document
305
+ && ((isRangeTarget && acceptFirstBaseline) || declaresAcceptIntent)
306
+ ? resolveOperationSourceRange(session.document, operation.target, operation.targetRef, operation.targetEndRef, runtimeContext, operationOptions)
307
+ : null;
308
+ const rangeEndIndex = sourceRange ? sourceRange.to : null;
309
+
242
310
  try {
243
311
  let result;
244
312
  if (operation.operationKind === 'comment_reply') {
@@ -333,6 +401,17 @@ export async function applyOperationToDocumentXml(documentXml, op, author, runti
333
401
  runtimeContext,
334
402
  operationOptions
335
403
  );
404
+ } else if (operation.operationKind === 'list-format') {
405
+ result = await applyListFormatToParagraphByExactText(
406
+ documentXml,
407
+ operation.target,
408
+ operation.list,
409
+ authorUsed,
410
+ operation.targetRef,
411
+ operation.targetEndRef,
412
+ runtimeContext,
413
+ operationOptions
414
+ );
336
415
  } else if (operation.operationKind === 'rejected-insert') {
337
416
  result = await insertIntoRejectedDeletedText(
338
417
  documentXml,
@@ -435,6 +514,142 @@ export async function applyOperationToDocumentXml(documentXml, op, author, runti
435
514
  ...resolutionCapture
436
515
  };
437
516
  }
517
+ if (operation._localizedReplacementCompilation && operation.structuredContent !== true) {
518
+ // Accept-view intent: the target's accepted text must be exactly the compiled
519
+ // text, whatever path produced it. A tracked whole-paragraph deletion rejects
520
+ // back to the source, so the Reject All verifier alone cannot see it. Structured (Markdown)
521
+ // replacement text legitimately differs from its literal accepted text, so it is exempt.
522
+ let intentText = null;
523
+ if (operation.operationKind === 'restore') {
524
+ const restored = operationOptions._mutationLiveNodes.find(node => node?.localName === 'p');
525
+ if (restored) intentText = extractCanonicalParagraphText(restored, { revisionView: 'accepted' });
526
+ } else if (operation._compiledSourceId && session.sourceTargetRegistry) {
527
+ const live = session.sourceTargetRegistry.previewSuccessor(
528
+ operation._compiledSourceId,
529
+ operationOptions._mutationRemovedNodes,
530
+ operationOptions._mutationLiveNodes
531
+ );
532
+ if (live) intentText = extractCanonicalParagraphText(live, { revisionView: 'accepted' });
533
+ }
534
+ if (intentText !== operation._localizedReplacementCompilation.desiredText) {
535
+ session.restoreSavepoint(savepoint);
536
+ const message = 'The localized replacement did not produce the compiled accepted text, so it was refused before commit to avoid losing content. Re-extract the paragraph and retry with its current text.';
537
+ const refusedReceipt = createEmptyReceipt(operationIndex, operation.operationId, authorUsed, 'refused');
538
+ refusedReceipt.warnings.push(message);
539
+ return {
540
+ documentXml,
541
+ hasChanges: false,
542
+ status: 'error',
543
+ error: normalizeOperationError({ code: 'PATCH_ROUNDTRIP_MISMATCH', stage: 'validation', message }, errorContext),
544
+ warnings: [message],
545
+ operationType: operation.operationKind,
546
+ authorUsed,
547
+ receipt: refusedReceipt,
548
+ ...resolutionCapture
549
+ };
550
+ }
551
+ }
552
+ const fidelityMode = resolveRevisionFidelityMode(options.verifyRevisionFidelity);
553
+ const singleTargetPosition = !operation.targetEndDescriptor && !operation.targetEndRef
554
+ ? resolutionCapture.resolvedTarget?.index
555
+ : null;
556
+ const fidelity = fidelityMode === 'off' ? null
557
+ : (operationOptions.generateRedlines !== false ? 'tracked' : (Number.isInteger(singleTargetPosition) ? 'untracked' : null));
558
+ if (fidelity === 'tracked') {
559
+ const baselineView = revisionFidelityBaselineView(operationOptions.existingRevisions);
560
+ // Accept-first policies accept revisions in the resolved target only. A range target uses the
561
+ // end resolved before mutation; if that could not be resolved the verifier falls back to its
562
+ // weaker region heuristic.
563
+ const targetPosition = resolutionCapture.resolvedTarget?.index;
564
+ const acceptedScope = !Number.isInteger(targetPosition) ? null
565
+ : !isRangeTarget ? { from: targetPosition - 1, to: targetPosition - 1 }
566
+ : (Number.isInteger(rangeEndIndex) && rangeEndIndex >= targetPosition - 1
567
+ ? { from: targetPosition - 1, to: rangeEndIndex }
568
+ : null);
569
+ const fidelity = verifyRevisionFidelity({
570
+ before: savepoint.document,
571
+ after: session.document,
572
+ baselineView,
573
+ acceptedScope,
574
+ beforeViews,
575
+ afterViews
576
+ });
577
+ const numbering = verifyNumberingReferences({
578
+ before: savepoint.document,
579
+ after: session.document,
580
+ numberingXmls: [runtimeContext?.numberingXml || options.numberingXml, ...(session.numberingXmlParts || []), result.numberingXml],
581
+ beforeViews,
582
+ afterViews
583
+ });
584
+ if (!numbering.ok) {
585
+ fidelity.ok = false;
586
+ fidelity.violations = [...(fidelity.violations || []), ...numbering.violations];
587
+ }
588
+ if (declaresAcceptIntent && sourceRange) {
589
+ const intent = verifyAcceptIntent({ before: savepoint.document, after: session.document, range: sourceRange, modified: operation.modified, beforeViews, afterViews });
590
+ if (!intent.ok) {
591
+ fidelity.ok = false;
592
+ fidelity.violations = [...(fidelity.violations || []), ...intent.violations];
593
+ }
594
+ }
595
+ if (!fidelity.ok) {
596
+ const fidelityError = buildRevisionFidelityError(fidelity.violations, { mode: fidelityMode, baselineView });
597
+ const logLine = `${fidelityError.code} (operation ${operationIndex}${operation.operationId ? ` "${operation.operationId}"` : ''}): ${fidelityError.message}`;
598
+ if (typeof options?.onWarn === 'function') options.onWarn(logLine);
599
+ if (fidelityMode === 'enforce') {
600
+ session.restoreSavepoint(savepoint);
601
+ session._fidelityViews = beforeViews;
602
+ const refusedReceipt = createEmptyReceipt(operationIndex, operation.operationId, authorUsed, 'refused');
603
+ refusedReceipt.warnings.push(logLine);
604
+ return {
605
+ documentXml,
606
+ hasChanges: false,
607
+ status: 'error',
608
+ error: normalizeOperationError(fidelityError, errorContext),
609
+ warnings: [logLine],
610
+ operationType: operation.operationKind,
611
+ authorUsed,
612
+ receipt: refusedReceipt,
613
+ ...resolutionCapture
614
+ };
615
+ }
616
+ result.warnings = [...(Array.isArray(result.warnings) ? result.warnings : []), logLine];
617
+ result.revisionFidelityViolations = fidelity.violations;
618
+ }
619
+ } else {
620
+ let locality = fidelity === 'untracked'
621
+ ? verifyUntrackedLocality({ before: savepoint.document, after: session.document, targetSourceIndex: singleTargetPosition - 1, beforeViews, afterViews })
622
+ : { ok: true };
623
+ if (locality.ok && fidelityMode !== 'off' && operationOptions.generateRedlines === false) {
624
+ locality = verifyNoCreatedRevisions({ before: savepoint.document, after: session.document, beforeViews, afterViews });
625
+ }
626
+ if (!locality.ok) {
627
+ const fidelityError = buildRevisionFidelityError(locality.violations, { mode: fidelityMode });
628
+ const logLine = `${fidelityError.code} (operation ${operationIndex}${operation.operationId ? ` "${operation.operationId}"` : ''}): ${fidelityError.message}`;
629
+ if (typeof options?.onWarn === 'function') options.onWarn(logLine);
630
+ if (fidelityMode === 'enforce') {
631
+ session.restoreSavepoint(savepoint);
632
+ session._fidelityViews = beforeViews;
633
+ const refusedReceipt = createEmptyReceipt(operationIndex, operation.operationId, authorUsed, 'refused');
634
+ refusedReceipt.warnings.push(logLine);
635
+ return {
636
+ documentXml,
637
+ hasChanges: false,
638
+ status: 'error',
639
+ error: normalizeOperationError(fidelityError, errorContext),
640
+ warnings: [logLine],
641
+ operationType: operation.operationKind,
642
+ authorUsed,
643
+ receipt: refusedReceipt,
644
+ ...resolutionCapture
645
+ };
646
+ }
647
+ result.warnings = [...(Array.isArray(result.warnings) ? result.warnings : []), logLine];
648
+ result.revisionFidelityViolations = locality.violations;
649
+ }
650
+ }
651
+ // Both verified branches leave the after-state's projections as the next operation's baseline.
652
+ if (fidelity === 'tracked' || fidelity === 'untracked') session._fidelityViews = afterViews;
438
653
  session.markMutationCommitted(true);
439
654
  if (operation._compiledSourceId && session.sourceTargetRegistry) {
440
655
  session.sourceTargetRegistry.commitMutation(