@ansonlai/docx-redline-js 0.8.4 → 0.10.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 (72) hide show
  1. package/ARCHITECTURE.md +679 -460
  2. package/CHANGELOG.md +208 -1
  3. package/README.md +152 -16
  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/core/word-xml.js +160 -118
  9. package/dist/docx-redline-js.esm.js +4338 -796
  10. package/dist/docx-redline-js.esm.js.map +4 -4
  11. package/dist/docx-redline-js.esm.min.js +111 -108
  12. package/dist/docx-redline-js.esm.min.js.map +4 -4
  13. package/dist/docx-redline.bundle.cjs +4463 -921
  14. package/dist/docx-redline.bundle.cjs.map +4 -4
  15. package/dist/docx-redline.bundle.js +4463 -921
  16. package/dist/docx-redline.bundle.js.map +4 -4
  17. package/docs/AGENT_FAST_START.md +2 -0
  18. package/docs/AGENT_KNOWLEDGE_BASE.md +49 -7
  19. package/docs/SKILL_AUTHORING.md +169 -150
  20. package/docs/TESTING.md +1146 -1059
  21. package/docs/schemas/document-operations.schema.json +55 -2
  22. package/document/docx-document.js +1022 -1010
  23. package/engine/oxml-engine.js +21 -22
  24. package/engine/paragraph-mark-revisions.js +292 -0
  25. package/engine/reconstruction-mapper.js +312 -311
  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 +53 -2
  32. package/index.js +13 -2
  33. package/node/cli-help.js +1 -0
  34. package/node/cli.js +21 -3
  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/markdown-processor.js +5 -2
  42. package/pipeline/pipeline.js +206 -204
  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 +23 -3
  52. package/services/capture-engine.js +228 -226
  53. package/services/comment-replies.js +1 -1
  54. package/services/document-operation-applier.js +214 -0
  55. package/services/document-operation-contract.js +76 -0
  56. package/services/document-operation-mutations.js +688 -51
  57. package/services/error-recovery.js +17 -0
  58. package/services/inspection-comparison.js +82 -0
  59. package/services/list-conversion-diagnostics.js +296 -0
  60. package/services/list-format-change.js +586 -0
  61. package/services/list-format-mutation.js +416 -0
  62. package/services/list-format.js +305 -0
  63. package/services/numbering-allocation.js +642 -0
  64. package/services/numbering-helpers.js +354 -524
  65. package/services/operation-batch-compiler.js +183 -11
  66. package/services/operation-preflight.js +43 -33
  67. package/services/receipt-collector.js +288 -288
  68. package/services/revision-comment-management.js +38 -5
  69. package/services/revision-fidelity-verifier.js +484 -0
  70. package/services/standalone-docx-plumbing.js +627 -631
  71. package/services/standalone-operation-runner.d.ts +182 -5
  72. package/services/table-reconciliation.js +59 -7
@@ -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,15 +258,23 @@ 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
+ plainTextContent: operation.structuredContent === false || (operation.structuredContent == null && options.structuredContent === false),
219
262
  ...(operation._localizedReplacementCompilation && operation.structuredContent !== true ? { literalTextEdit: true } : {}),
220
263
  pairReplacements: typeof operation.pairReplacements === 'boolean' ? operation.pairReplacements : (options.pairReplacements !== false),
221
264
  ...(operation.insertionAffinity ? { insertionAffinity: operation.insertionAffinity } : {}),
265
+ ...(operation.listMarkers ? { listMarkers: operation.listMarkers } : {}),
222
266
  ...(operation.formattingRevisionPolicy ? { formattingRevisionPolicy: operation.formattingRevisionPolicy } : {}),
223
267
  ...(operation.operationKind === 'format' && Number.isInteger(operation.textOccurrence) ? { textOccurrence: operation.textOccurrence } : {}),
224
268
  targetDescriptor: operation.targetDescriptor,
225
269
  targetEndDescriptor: operation.targetEndDescriptor,
226
270
  _compiledSourceId: operation._compiledSourceId || null,
227
271
  _compiledSourceEndId: operation._compiledSourceEndId || null,
272
+ ...(operation.operationKind === 'list-format' && operation.list?.continueFrom && typeof operation.list.continueFrom === 'object'
273
+ ? {
274
+ continueFromDescriptor: normalizeTargetDescriptor(operation.list.continueFrom, null, 'accepted'),
275
+ _compiledContinueFromId: operation._compiledContinueFromId || null
276
+ }
277
+ : {}),
228
278
  _compiledResolvedBy: operation._compiledResolvedBy || null,
229
279
  _sourceTargetRegistry: session.sourceTargetRegistry || null,
230
280
  _resolutionCapture: resolutionCapture,
@@ -241,6 +291,23 @@ export async function applyOperationToDocumentXml(documentXml, op, author, runti
241
291
  }
242
292
  };
243
293
 
294
+ // Resolve the target range before mutating, for the revision fidelity verifier: the exact accepted scope of an
295
+ // accept-first range target, and the target of a plain-text redline's ACCEPT_INTENT check.
296
+ const isRangeTarget = !!(operation.targetEndDescriptor || operation.targetEndRef);
297
+ const earlyFidelityMode = resolveRevisionFidelityMode(options.verifyRevisionFidelity);
298
+ const acceptFirstBaseline = revisionFidelityBaselineView(operationOptions.existingRevisions) === 'accepted';
299
+ const declaresAcceptIntent = operation.operationKind === 'redline'
300
+ && operationOptions.generateRedlines !== false
301
+ && !operation._localizedReplacementCompilation
302
+ && operation.structuredContent !== true
303
+ && isPlainTextIntent(operation.modified)
304
+ && !/\r?\n/.test(String(operation.targetDescriptor?.exactText ?? operation.targetDescriptor?.text ?? (typeof operation.target === 'string' ? operation.target : '')));
305
+ const sourceRange = earlyFidelityMode !== 'off' && session.document
306
+ && ((isRangeTarget && acceptFirstBaseline) || declaresAcceptIntent)
307
+ ? resolveOperationSourceRange(session.document, operation.target, operation.targetRef, operation.targetEndRef, runtimeContext, operationOptions)
308
+ : null;
309
+ const rangeEndIndex = sourceRange ? sourceRange.to : null;
310
+
244
311
  try {
245
312
  let result;
246
313
  if (operation.operationKind === 'comment_reply') {
@@ -335,6 +402,17 @@ export async function applyOperationToDocumentXml(documentXml, op, author, runti
335
402
  runtimeContext,
336
403
  operationOptions
337
404
  );
405
+ } else if (operation.operationKind === 'list-format') {
406
+ result = await applyListFormatToParagraphByExactText(
407
+ documentXml,
408
+ operation.target,
409
+ operation.list,
410
+ authorUsed,
411
+ operation.targetRef,
412
+ operation.targetEndRef,
413
+ runtimeContext,
414
+ operationOptions
415
+ );
338
416
  } else if (operation.operationKind === 'rejected-insert') {
339
417
  result = await insertIntoRejectedDeletedText(
340
418
  documentXml,
@@ -437,6 +515,142 @@ export async function applyOperationToDocumentXml(documentXml, op, author, runti
437
515
  ...resolutionCapture
438
516
  };
439
517
  }
518
+ if (operation._localizedReplacementCompilation && operation.structuredContent !== true) {
519
+ // Accept-view intent: the target's accepted text must be exactly the compiled
520
+ // text, whatever path produced it. A tracked whole-paragraph deletion rejects
521
+ // back to the source, so the Reject All verifier alone cannot see it. Structured (Markdown)
522
+ // replacement text legitimately differs from its literal accepted text, so it is exempt.
523
+ let intentText = null;
524
+ if (operation.operationKind === 'restore') {
525
+ const restored = operationOptions._mutationLiveNodes.find(node => node?.localName === 'p');
526
+ if (restored) intentText = extractCanonicalParagraphText(restored, { revisionView: 'accepted' });
527
+ } else if (operation._compiledSourceId && session.sourceTargetRegistry) {
528
+ const live = session.sourceTargetRegistry.previewSuccessor(
529
+ operation._compiledSourceId,
530
+ operationOptions._mutationRemovedNodes,
531
+ operationOptions._mutationLiveNodes
532
+ );
533
+ if (live) intentText = extractCanonicalParagraphText(live, { revisionView: 'accepted' });
534
+ }
535
+ if (intentText !== operation._localizedReplacementCompilation.desiredText) {
536
+ session.restoreSavepoint(savepoint);
537
+ 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.';
538
+ const refusedReceipt = createEmptyReceipt(operationIndex, operation.operationId, authorUsed, 'refused');
539
+ refusedReceipt.warnings.push(message);
540
+ return {
541
+ documentXml,
542
+ hasChanges: false,
543
+ status: 'error',
544
+ error: normalizeOperationError({ code: 'PATCH_ROUNDTRIP_MISMATCH', stage: 'validation', message }, errorContext),
545
+ warnings: [message],
546
+ operationType: operation.operationKind,
547
+ authorUsed,
548
+ receipt: refusedReceipt,
549
+ ...resolutionCapture
550
+ };
551
+ }
552
+ }
553
+ const fidelityMode = resolveRevisionFidelityMode(options.verifyRevisionFidelity);
554
+ const singleTargetPosition = !operation.targetEndDescriptor && !operation.targetEndRef
555
+ ? resolutionCapture.resolvedTarget?.index
556
+ : null;
557
+ const fidelity = fidelityMode === 'off' ? null
558
+ : (operationOptions.generateRedlines !== false ? 'tracked' : (Number.isInteger(singleTargetPosition) ? 'untracked' : null));
559
+ if (fidelity === 'tracked') {
560
+ const baselineView = revisionFidelityBaselineView(operationOptions.existingRevisions);
561
+ // Accept-first policies accept revisions in the resolved target only. A range target uses the
562
+ // end resolved before mutation; if that could not be resolved the verifier falls back to its
563
+ // weaker region heuristic.
564
+ const targetPosition = resolutionCapture.resolvedTarget?.index;
565
+ const acceptedScope = !Number.isInteger(targetPosition) ? null
566
+ : !isRangeTarget ? { from: targetPosition - 1, to: targetPosition - 1 }
567
+ : (Number.isInteger(rangeEndIndex) && rangeEndIndex >= targetPosition - 1
568
+ ? { from: targetPosition - 1, to: rangeEndIndex }
569
+ : null);
570
+ const fidelity = verifyRevisionFidelity({
571
+ before: savepoint.document,
572
+ after: session.document,
573
+ baselineView,
574
+ acceptedScope,
575
+ beforeViews,
576
+ afterViews
577
+ });
578
+ const numbering = verifyNumberingReferences({
579
+ before: savepoint.document,
580
+ after: session.document,
581
+ numberingXmls: [runtimeContext?.numberingXml || options.numberingXml, ...(session.numberingXmlParts || []), result.numberingXml],
582
+ beforeViews,
583
+ afterViews
584
+ });
585
+ if (!numbering.ok) {
586
+ fidelity.ok = false;
587
+ fidelity.violations = [...(fidelity.violations || []), ...numbering.violations];
588
+ }
589
+ if (declaresAcceptIntent && sourceRange) {
590
+ const intent = verifyAcceptIntent({ before: savepoint.document, after: session.document, range: sourceRange, modified: operation.modified, beforeViews, afterViews });
591
+ if (!intent.ok) {
592
+ fidelity.ok = false;
593
+ fidelity.violations = [...(fidelity.violations || []), ...intent.violations];
594
+ }
595
+ }
596
+ if (!fidelity.ok) {
597
+ const fidelityError = buildRevisionFidelityError(fidelity.violations, { mode: fidelityMode, baselineView });
598
+ const logLine = `${fidelityError.code} (operation ${operationIndex}${operation.operationId ? ` "${operation.operationId}"` : ''}): ${fidelityError.message}`;
599
+ if (typeof options?.onWarn === 'function') options.onWarn(logLine);
600
+ if (fidelityMode === 'enforce') {
601
+ session.restoreSavepoint(savepoint);
602
+ session._fidelityViews = beforeViews;
603
+ const refusedReceipt = createEmptyReceipt(operationIndex, operation.operationId, authorUsed, 'refused');
604
+ refusedReceipt.warnings.push(logLine);
605
+ return {
606
+ documentXml,
607
+ hasChanges: false,
608
+ status: 'error',
609
+ error: normalizeOperationError(fidelityError, errorContext),
610
+ warnings: [logLine],
611
+ operationType: operation.operationKind,
612
+ authorUsed,
613
+ receipt: refusedReceipt,
614
+ ...resolutionCapture
615
+ };
616
+ }
617
+ result.warnings = [...(Array.isArray(result.warnings) ? result.warnings : []), logLine];
618
+ result.revisionFidelityViolations = fidelity.violations;
619
+ }
620
+ } else {
621
+ let locality = fidelity === 'untracked'
622
+ ? verifyUntrackedLocality({ before: savepoint.document, after: session.document, targetSourceIndex: singleTargetPosition - 1, beforeViews, afterViews })
623
+ : { ok: true };
624
+ if (locality.ok && fidelityMode !== 'off' && operationOptions.generateRedlines === false) {
625
+ locality = verifyNoCreatedRevisions({ before: savepoint.document, after: session.document, beforeViews, afterViews });
626
+ }
627
+ if (!locality.ok) {
628
+ const fidelityError = buildRevisionFidelityError(locality.violations, { mode: fidelityMode });
629
+ const logLine = `${fidelityError.code} (operation ${operationIndex}${operation.operationId ? ` "${operation.operationId}"` : ''}): ${fidelityError.message}`;
630
+ if (typeof options?.onWarn === 'function') options.onWarn(logLine);
631
+ if (fidelityMode === 'enforce') {
632
+ session.restoreSavepoint(savepoint);
633
+ session._fidelityViews = beforeViews;
634
+ const refusedReceipt = createEmptyReceipt(operationIndex, operation.operationId, authorUsed, 'refused');
635
+ refusedReceipt.warnings.push(logLine);
636
+ return {
637
+ documentXml,
638
+ hasChanges: false,
639
+ status: 'error',
640
+ error: normalizeOperationError(fidelityError, errorContext),
641
+ warnings: [logLine],
642
+ operationType: operation.operationKind,
643
+ authorUsed,
644
+ receipt: refusedReceipt,
645
+ ...resolutionCapture
646
+ };
647
+ }
648
+ result.warnings = [...(Array.isArray(result.warnings) ? result.warnings : []), logLine];
649
+ result.revisionFidelityViolations = locality.violations;
650
+ }
651
+ }
652
+ // Both verified branches leave the after-state's projections as the next operation's baseline.
653
+ if (fidelity === 'tracked' || fidelity === 'untracked') session._fidelityViews = afterViews;
440
654
  session.markMutationCommitted(true);
441
655
  if (operation._compiledSourceId && session.sourceTargetRegistry) {
442
656
  session.sourceTargetRegistry.commitMutation(
@@ -6,6 +6,8 @@
6
6
  */
7
7
 
8
8
  import { validateExactReplacementRequests } from './localized-replacement-compiler.js';
9
+ import { validateParagraphNumberingShape, findUnknownParagraphFormatKeys, PARAGRAPH_FORMAT_PROPERTY_KEYS } from './list-conversion-diagnostics.js';
10
+ import { validateListFormatShape } from './list-format.js';
9
11
 
10
12
  const SUPPORTED_OPERATION_TYPES = new Set([
11
13
  'redline',
@@ -13,6 +15,7 @@ const SUPPORTED_OPERATION_TYPES = new Set([
13
15
  'format',
14
16
  'character-format',
15
17
  'paragraph-format',
18
+ 'list-format',
16
19
  'list-change',
17
20
  'table-reconciliation',
18
21
  'insert',
@@ -25,6 +28,7 @@ const SUPPORTED_OPERATION_TYPES = new Set([
25
28
  ]);
26
29
 
27
30
  export const EXISTING_REVISIONS_POLICIES = Object.freeze([
31
+ 'auto',
28
32
  'merge-same-author',
29
33
  'slice-cross-author',
30
34
  'reject-input',
@@ -55,6 +59,7 @@ export function getCanonicalOperationType(operation) {
55
59
  if (type === 'restore') return 'restore';
56
60
  if (type === 'comment' || type === 'comment_reply' || type === 'comment_resolve' || type === 'highlight') return type;
57
61
  if (type === 'paragraph-format') return 'paragraph-format';
62
+ if (type === 'list-format') return 'list-format';
58
63
  if (type === 'character-format' || (type === 'format' && (operation?.textToFormat != null || operation?.properties != null))) return 'format';
59
64
  return 'redline';
60
65
  }
@@ -408,6 +413,13 @@ export function validateDocumentOperation(operation) {
408
413
  };
409
414
  }
410
415
 
416
+ if (normalized.listMarkers != null && normalized.listMarkers !== 'literal') {
417
+ return {
418
+ valid: false,
419
+ error: { code: 'INVALID_OPERATION', message: 'listMarkers must be "literal" when provided.' }
420
+ };
421
+ }
422
+
411
423
  if (normalized.existingRevisions != null && !isExistingRevisionsPolicy(normalized.existingRevisions)) {
412
424
  return {
413
425
  valid: false,
@@ -534,6 +546,70 @@ export function validateDocumentOperation(operation) {
534
546
  error: { code: 'INVALID_OPERATION', message: 'Paragraph-format operations require a "properties" object.' }
535
547
  };
536
548
  }
549
+ const unknownKeys = findUnknownParagraphFormatKeys(normalized.properties);
550
+ if (unknownKeys.length > 0) {
551
+ return {
552
+ valid: false,
553
+ error: {
554
+ code: 'INVALID_OPERATION',
555
+ message: `paragraph-format does not support ${unknownKeys.length === 1 ? 'property' : 'properties'} ${unknownKeys.map(key => `"${key}"`).join(', ')}. Supported: ${PARAGRAPH_FORMAT_PROPERTY_KEYS.join(', ')}.`,
556
+ field: `properties.${unknownKeys[0]}`,
557
+ properties: unknownKeys,
558
+ recovery: { action: 'change_request', hint: `Use only: ${PARAGRAPH_FORMAT_PROPERTY_KEYS.join(', ')}.` }
559
+ }
560
+ };
561
+ }
562
+ if (normalized.properties.numbering !== undefined) {
563
+ const shapeError = validateParagraphNumberingShape(normalized.properties.numbering);
564
+ if (shapeError) {
565
+ return {
566
+ valid: false,
567
+ error: {
568
+ code: 'INVALID_OPERATION',
569
+ message: shapeError,
570
+ field: 'properties.numbering',
571
+ recovery: { action: 'change_request', hint: 'Pass numbering as { "numId": <existing numId from inspect>, "level": 0 } or null.' }
572
+ }
573
+ };
574
+ }
575
+ }
576
+ }
577
+
578
+ if (normalized.operationKind === 'list-format') {
579
+ const shapeError = validateListFormatShape(normalized.list);
580
+ if (shapeError) {
581
+ return {
582
+ valid: false,
583
+ error: {
584
+ code: 'INVALID_OPERATION',
585
+ message: shapeError.message,
586
+ field: shapeError.field,
587
+ recovery: {
588
+ action: 'change_request',
589
+ hint: 'Pass list as { "mode": "convert-plain" } (optional format, kind, level, strip "marker" | "none", start with strip "none"), { "mode": "change-format", "format": ... } (optional kind, lvlText, start, level, scope "list" | "range") or { "mode": "join", "continueFrom": { "paragraphId": ... } } (optional scope).'
590
+ }
591
+ }
592
+ };
593
+ }
594
+ if (operation.modified !== undefined || operation.replacements !== undefined) {
595
+ return {
596
+ valid: false,
597
+ error: {
598
+ code: 'INVALID_OPERATION',
599
+ message: 'list-format changes list structure only; express text changes as a separate redline operation.',
600
+ field: operation.modified !== undefined ? 'modified' : 'replacements'
601
+ }
602
+ };
603
+ }
604
+ if (target.captureRef || target.occurrence != null || target.revisionView !== 'accepted') {
605
+ return {
606
+ valid: false,
607
+ error: {
608
+ code: 'INVALID_OPERATION',
609
+ message: 'list-format v1 targets batch-start accepted-view paragraphs without captures or target occurrence.'
610
+ }
611
+ };
612
+ }
537
613
  }
538
614
 
539
615
  if (normalized.author != null && !nonEmptyString(normalized.author)) {