@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,537 +1,628 @@
1
- /**
2
- * Public document-operation normalization and validation.
3
- *
4
- * Keep compatibility aliases at this boundary so the runner and preflight
5
- * logic can operate on one internal shape.
6
- */
7
-
8
- import { validateExactReplacementRequests } from './localized-replacement-compiler.js';
9
-
10
- const SUPPORTED_OPERATION_TYPES = new Set([
11
- 'redline',
12
- 'replace',
13
- 'format',
14
- 'character-format',
15
- 'paragraph-format',
16
- 'list-change',
17
- 'table-reconciliation',
18
- 'insert',
19
- 'restore',
20
- 'delete',
21
- 'comment',
22
- 'comment_reply',
23
- 'comment_resolve',
24
- 'highlight'
25
- ]);
26
-
27
- export const EXISTING_REVISIONS_POLICIES = Object.freeze([
28
- 'merge-same-author',
29
- 'slice-cross-author',
30
- 'reject-input',
31
- 'accept-all-first',
32
- 'accept-all-first-keep-normalized'
33
- ]);
34
-
35
- export function isExistingRevisionsPolicy(value) {
36
- return EXISTING_REVISIONS_POLICIES.includes(value);
37
- }
38
-
39
- function isRecord(value) {
40
- return !!value && typeof value === 'object' && !Array.isArray(value);
41
- }
42
-
43
- function nonEmptyString(value) {
44
- return typeof value === 'string' && value.trim().length > 0;
45
- }
46
-
47
- /** Operations that address an existing comment thread by id instead of targeting a paragraph. */
48
- export function isCommentThreadOperationKind(kind) {
49
- return kind === 'comment_reply' || kind === 'comment_resolve';
50
- }
51
-
52
- export function getCanonicalOperationType(operation) {
53
- const type = operation?.type;
54
- if (type === 'insert' && operation?.target?.revisionView === 'rejected') return 'rejected-insert';
55
- if (type === 'restore') return 'restore';
56
- if (type === 'comment' || type === 'comment_reply' || type === 'comment_resolve' || type === 'highlight') return type;
57
- if (type === 'paragraph-format') return 'paragraph-format';
58
- if (type === 'character-format' || (type === 'format' && (operation?.textToFormat != null || operation?.properties != null))) return 'format';
59
- return 'redline';
60
- }
61
-
62
- export function normalizeTargetDescriptor(target, legacyTargetRef = null, defaultRevisionView = 'accepted') {
63
- const fallbackRevisionView = defaultRevisionView === 'rejected' ? 'rejected' : 'accepted';
64
- if (!isRecord(target)) {
65
- return {
66
- text: typeof target === 'string' ? target : '',
67
- index: legacyTargetRef ?? null,
68
- paragraphId: null,
69
- occurrence: null,
70
- inTable: null,
71
- fingerprint: null,
72
- revisionView: fallbackRevisionView
73
- };
74
- }
75
-
76
- return {
77
- text: typeof target.exactText === 'string'
78
- ? target.exactText
79
- : (typeof target.text === 'string' ? target.text : ''),
80
- index: target.index ?? target.paragraphIndex ?? legacyTargetRef ?? null,
81
- paragraphId: nonEmptyString(target.paragraphId) ? target.paragraphId.trim() : null,
82
- occurrence: Number.isInteger(target.occurrence) && target.occurrence > 0
83
- ? target.occurrence
84
- : null,
85
- inTable: typeof target.inTable === 'boolean' ? target.inTable : null,
86
- fingerprint: nonEmptyString(target.fingerprint)
87
- ? target.fingerprint.trim()
88
- : (nonEmptyString(target.sourceFingerprint) ? target.sourceFingerprint.trim() : null),
89
- revisionView: target.revisionView === 'rejected'
90
- ? 'rejected'
91
- : (target.revisionView === 'accepted' ? 'accepted' : fallbackRevisionView),
92
- captureRef: nonEmptyString(target.captureRef) ? target.captureRef.trim() : null,
93
- select: typeof target.select === 'string' ? target.select : null
94
- };
95
- }
96
-
97
- export function normalizeDocumentOperation(operation) {
98
- const source = isRecord(operation) ? operation : {};
99
- const kind = getCanonicalOperationType(source);
100
- const defaultRevisionView = kind === 'restore' ? 'rejected' : 'accepted';
101
- const targetDescriptor = normalizeTargetDescriptor(source.target, source.targetRef, defaultRevisionView);
102
- const targetEndDescriptor = source.targetEnd != null
103
- ? normalizeTargetDescriptor(source.targetEnd, source.targetEndRef, defaultRevisionView)
104
- : null;
105
-
106
- return {
107
- ...source,
108
- operationId: nonEmptyString(source.operationId) ? source.operationId.trim() : null,
109
- captureKey: nonEmptyString(source.captureKey) ? source.captureKey.trim() : null,
110
- operationKind: kind,
111
- anchor: isRecord(source.anchor) ? {
112
- exactText: typeof source.anchor.exactText === 'string' ? source.anchor.exactText : '',
113
- occurrence: Number.isInteger(source.anchor.occurrence) && source.anchor.occurrence > 0 ? source.anchor.occurrence : 1,
114
- occurrenceExplicit: Number.isInteger(source.anchor.occurrence) && source.anchor.occurrence > 0,
115
- offset: Number.isInteger(source.anchor.offset) ? source.anchor.offset : null
116
- } : null,
117
- targetDescriptor,
118
- targetEndDescriptor,
119
- target: targetDescriptor.text,
120
- targetRef: targetDescriptor.index,
121
- targetEndRef: targetEndDescriptor?.index ?? source.targetEndRef ?? null,
122
- ...(source.type === 'delete' && source.modified == null ? { modified: '' } : {})
123
- };
124
- }
125
-
126
- export function validateDocumentOperation(operation) {
127
- if (!isRecord(operation)) {
128
- return {
129
- valid: false,
130
- error: { code: 'INVALID_OPERATION', message: 'Operation must be an object.' }
131
- };
132
- }
133
-
134
- const rawType = operation.type;
135
- if (rawType != null && rawType !== '' && !SUPPORTED_OPERATION_TYPES.has(rawType)) {
136
- return {
137
- valid: false,
138
- error: {
139
- code: 'INVALID_OPERATION',
140
- message: `Unsupported operation type: "${String(rawType)}".`
141
- }
142
- };
143
- }
144
-
145
- const normalized = normalizeDocumentOperation(operation);
146
- if (isRecord(operation.target) && operation.target.revisionView != null) {
147
- if (operation.target.revisionView !== 'accepted' && operation.target.revisionView !== 'rejected') {
148
- return {
149
- valid: false,
150
- error: {
151
- code: 'INVALID_OPERATION',
152
- message: 'Target revisionView must be "accepted" or "rejected" when provided.'
153
- }
154
- };
155
- }
156
- }
157
-
158
- if (isRecord(operation.targetEnd) && operation.targetEnd.revisionView != null) {
159
- if (operation.targetEnd.revisionView !== 'accepted' && operation.targetEnd.revisionView !== 'rejected') {
160
- return {
161
- valid: false,
162
- error: {
163
- code: 'INVALID_OPERATION',
164
- message: 'Target revisionView must be "accepted" or "rejected" when provided.'
165
- }
166
- };
167
- }
168
- }
169
-
170
- if (operation.operationId != null && (!nonEmptyString(operation.operationId) || operation.operationId.trim().length > 256)) {
171
- return {
172
- valid: false,
173
- error: { code: 'INVALID_OPERATION', message: 'operationId must be a non-empty string under 256 characters when provided.' }
174
- };
175
- }
176
-
177
- if (operation.captureKey != null && (!nonEmptyString(operation.captureKey) || operation.captureKey.trim().length > 256)) {
178
- return {
179
- valid: false,
180
- error: { code: 'INVALID_OPERATION', message: 'captureKey must be a non-empty string under 256 characters when provided.' }
181
- };
182
- }
183
-
184
- if (isRecord(operation.target) && operation.target.captureRef != null && (!nonEmptyString(operation.target.captureRef) || operation.target.captureRef.trim().length > 256)) {
185
- return {
186
- valid: false,
187
- error: { code: 'INVALID_OPERATION', message: 'target.captureRef must be a non-empty string under 256 characters when provided.' }
188
- };
189
- }
190
-
191
- if (isRecord(operation.target) && operation.target.select != null && typeof operation.target.select !== 'string') {
192
- return {
193
- valid: false,
194
- error: { code: 'INVALID_OPERATION', message: 'target.select must be a string when provided.' }
195
- };
196
- }
197
-
198
- const target = normalized.targetDescriptor;
199
- if (
200
- !isCommentThreadOperationKind(normalized.operationKind)
201
- && !nonEmptyString(target.text)
202
- && target.index == null
203
- && !target.paragraphId
204
- && !target.fingerprint
205
- && !target.captureRef
206
- ) {
207
- return {
208
- valid: false,
209
- error: {
210
- code: 'INVALID_OPERATION',
211
- message: 'Operation target must provide text, a paragraph index, a paragraphId, or a captureRef.'
212
- }
213
- };
214
- }
215
-
216
- if (target.occurrence != null && !nonEmptyString(target.text)) {
217
- return {
218
- valid: false,
219
- error: {
220
- code: 'INVALID_OPERATION',
221
- message: 'Target occurrence requires target text.'
222
- }
223
- };
224
- }
225
-
226
- if (normalized.operationKind === 'redline') {
227
- const hasModified = typeof normalized.modified === 'string';
228
- const hasReplacements = normalized.replacements !== undefined;
229
- if (hasModified === hasReplacements) {
230
- return {
231
- valid: false,
232
- error: {
233
- code: 'INVALID_OPERATION',
234
- message: 'Redline operations require exactly one of string "modified" or non-empty "replacements".'
235
- }
236
- };
237
- }
238
- if (hasReplacements) {
239
- if (rawType != null && rawType !== '' && !['redline', 'replace'].includes(rawType)) {
240
- return {
241
- valid: false,
242
- error: {
243
- code: 'INVALID_OPERATION',
244
- message: 'Localized replacements are supported only for redline or replace operations.'
245
- }
246
- };
247
- }
248
- if (
249
- target.revisionView !== 'accepted'
250
- || target.captureRef
251
- || target.occurrence != null
252
- || normalized.captureKey
253
- || normalized.targetEndDescriptor
254
- || normalized.targetEndRef != null
255
- || /\r|\n/.test(target.text || '')
256
- ) {
257
- return {
258
- valid: false,
259
- error: {
260
- code: 'INVALID_OPERATION',
261
- message: 'Localized replacements v1 require one batch-start accepted-view paragraph target without captures, target occurrence, or a target range.'
262
- }
263
- };
264
- }
265
- const replacementValidation = validateExactReplacementRequests(normalized.replacements);
266
- if (!replacementValidation.ok) {
267
- return { valid: false, error: replacementValidation.error };
268
- }
269
- if (replacementValidation.replacements.some(replacement => /\r|\n/.test(replacement.replace))) {
270
- return {
271
- valid: false,
272
- error: {
273
- code: 'INVALID_OPERATION',
274
- message: 'Localized replacements v1 cannot insert paragraph breaks.'
275
- }
276
- };
277
- }
278
- normalized.replacements = replacementValidation.replacements;
279
- }
280
- }
281
-
282
- if (normalized.operationKind === 'rejected-insert') {
283
- if (!nonEmptyString(normalized.modified)) {
284
- return {
285
- valid: false,
286
- error: { code: 'INVALID_OPERATION', message: 'Rejected-view insert operations require non-empty string "modified" text.' }
287
- };
288
- }
289
- if (
290
- !normalized.anchor
291
- || !nonEmptyString(normalized.anchor.exactText)
292
- || !Number.isInteger(normalized.anchor.offset)
293
- || normalized.anchor.offset < 0
294
- || normalized.anchor.offset > normalized.anchor.exactText.length
295
- ) {
296
- return {
297
- valid: false,
298
- error: {
299
- code: 'INVALID_OPERATION',
300
- message: 'Rejected-view insert operations require anchor.exactText, a positive occurrence, and an offset within the anchor text.'
301
- }
302
- };
303
- }
304
- if (normalized.existingRevisions !== 'slice-cross-author') {
305
- return {
306
- valid: false,
307
- error: {
308
- code: 'INVALID_OPERATION',
309
- message: 'Rejected-view insert operations require existingRevisions: "slice-cross-author".'
310
- }
311
- };
312
- }
313
- }
314
-
315
- if (normalized.operationKind === 'restore') {
316
- const hasModified = operation.modified !== undefined;
317
- const hasReplacements = operation.replacements !== undefined;
318
- const validSingle = nonEmptyString(normalized.modified);
319
- const validRange = Array.isArray(normalized.modified)
320
- && normalized.modified.length > 0
321
- && normalized.modified.every(nonEmptyString);
322
- if (hasModified && hasReplacements) {
323
- return {
324
- valid: false,
325
- error: {
326
- code: 'INVALID_OPERATION',
327
- message: 'Restore operations accept modified or replacements, not both.'
328
- }
329
- };
330
- }
331
- if (hasModified && !validSingle && !validRange) {
332
- return {
333
- valid: false,
334
- error: {
335
- code: 'INVALID_OPERATION',
336
- message: 'Restore modified must be a non-empty string or non-empty string array.'
337
- }
338
- };
339
- }
340
- if (!hasModified) {
341
- if (
342
- target.revisionView !== 'rejected'
343
- || (!target.paragraphId && !target.fingerprint)
344
- || target.captureRef
345
- || target.occurrence != null
346
- || normalized.captureKey
347
- || normalized.targetEndDescriptor
348
- || normalized.targetEndRef != null
349
- || /\r|\n/.test(target.text || '')
350
- ) {
351
- return {
352
- valid: false,
353
- error: {
354
- code: 'INVALID_OPERATION',
355
- message: 'Verbatim and localized restore require one strong batch-start rejected-view paragraph target without captures, target occurrence, or a target range.'
356
- }
357
- };
358
- }
359
- }
360
- if (hasReplacements) {
361
- const replacementValidation = validateExactReplacementRequests(normalized.replacements);
362
- if (!replacementValidation.ok) {
363
- return { valid: false, error: replacementValidation.error };
364
- }
365
- if (replacementValidation.replacements.some(replacement => /\r|\n/.test(replacement.replace))) {
366
- return {
367
- valid: false,
368
- error: {
369
- code: 'INVALID_OPERATION',
370
- message: 'Localized restore cannot insert paragraph breaks.'
371
- }
372
- };
373
- }
374
- normalized.replacements = replacementValidation.replacements;
375
- }
376
- if (normalized.generateRedlines === false) {
377
- return {
378
- valid: false,
379
- error: {
380
- code: 'INVALID_OPERATION',
381
- message: 'Restore operations require tracked changes and cannot set generateRedlines to false.'
382
- }
383
- };
384
- }
385
- }
386
-
387
- if (normalized.structuredContent != null && typeof normalized.structuredContent !== 'boolean') {
388
- return {
389
- valid: false,
390
- error: { code: 'INVALID_OPERATION', message: 'structuredContent must be a boolean when provided.' }
391
- };
392
- }
393
-
394
- if (normalized.pairReplacements != null && typeof normalized.pairReplacements !== 'boolean') {
395
- return {
396
- valid: false,
397
- error: { code: 'INVALID_OPERATION', message: 'pairReplacements must be a boolean when provided.' }
398
- };
399
- }
400
-
401
- if (normalized.existingRevisions != null && !isExistingRevisionsPolicy(normalized.existingRevisions)) {
402
- return {
403
- valid: false,
404
- error: {
405
- code: 'INVALID_OPERATION',
406
- message: `existingRevisions must be one of: ${EXISTING_REVISIONS_POLICIES.join(', ')}.`
407
- }
408
- };
409
- }
410
-
411
- if (normalized.insertionAffinity != null) {
412
- if (!isRecord(normalized.insertionAffinity)) {
413
- return {
414
- valid: false,
415
- error: { code: 'INVALID_OPERATION', message: 'insertionAffinity must be an object when provided.' }
416
- };
417
- }
418
- const { formatting, hyperlink, revision, bookmark, comment } = normalized.insertionAffinity;
419
- if (formatting != null && !['left', 'right', 'none'].includes(formatting)) {
420
- return {
421
- valid: false,
422
- error: { code: 'INVALID_OPERATION', message: 'insertionAffinity.formatting must be "left", "right", or "none".' }
423
- };
424
- }
425
- if (hyperlink != null && !['inside', 'outside', 'preserve'].includes(hyperlink)) {
426
- return {
427
- valid: false,
428
- error: { code: 'INVALID_OPERATION', message: 'insertionAffinity.hyperlink must be "inside", "outside", or "preserve".' }
429
- };
430
- }
431
- if (revision != null && !['coalesce_same_author', 'separate'].includes(revision)) {
432
- return {
433
- valid: false,
434
- error: { code: 'INVALID_OPERATION', message: 'insertionAffinity.revision must be "coalesce_same_author" or "separate".' }
435
- };
436
- }
437
- if (bookmark != null && !['inside', 'outside'].includes(bookmark)) {
438
- return {
439
- valid: false,
440
- error: { code: 'INVALID_OPERATION', message: 'insertionAffinity.bookmark must be "inside" or "outside".' }
441
- };
442
- }
443
- if (comment != null && !['inside', 'outside'].includes(comment)) {
444
- return {
445
- valid: false,
446
- error: { code: 'INVALID_OPERATION', message: 'insertionAffinity.comment must be "inside" or "outside".' }
447
- };
448
- }
449
- }
450
-
451
- if (normalized.operationKind === 'comment') {
452
- if (!nonEmptyString(normalized.commentContent)) {
453
- return {
454
- valid: false,
455
- error: { code: 'INVALID_OPERATION', message: 'Comment operations require a non-empty "commentContent" field.' }
456
- };
457
- }
458
- }
459
-
460
- if (normalized.operationKind === 'comment_reply') {
461
- if (!nonEmptyString(String(normalized.parentCommentId ?? ''))) {
462
- return { valid: false, error: { code: 'INVALID_OPERATION', message: 'Comment reply operations require a "parentCommentId".' } };
463
- }
464
- if (!nonEmptyString(normalized.commentContent)) {
465
- return { valid: false, error: { code: 'INVALID_OPERATION', message: 'Comment reply operations require a non-empty "commentContent" field.' } };
466
- }
467
- }
468
-
469
- if (normalized.operationKind === 'comment_resolve') {
470
- if (!nonEmptyString(String(normalized.commentId ?? ''))) {
471
- return { valid: false, error: { code: 'INVALID_OPERATION', message: 'Comment resolve operations require a "commentId".' } };
472
- }
473
- if (normalized.resolved != null && typeof normalized.resolved !== 'boolean') {
474
- return { valid: false, error: { code: 'INVALID_OPERATION', message: 'Comment resolve "resolved" must be a boolean when provided.' } };
475
- }
476
- }
477
-
478
- if (normalized.operationKind === 'highlight') {
479
- if (!nonEmptyString(normalized.textToHighlight)) {
480
- return {
481
- valid: false,
482
- error: { code: 'INVALID_OPERATION', message: 'Highlight operations require a non-empty "textToHighlight" field.' }
483
- };
484
- }
485
- if (normalized.color != null && !nonEmptyString(normalized.color)) {
486
- return {
487
- valid: false,
488
- error: { code: 'INVALID_OPERATION', message: 'Highlight color must be a non-empty string when provided.' }
489
- };
490
- }
491
- }
492
-
493
- if (normalized.operationKind === 'format') {
494
- if (!nonEmptyString(normalized.textToFormat)) {
495
- return {
496
- valid: false,
497
- error: { code: 'INVALID_OPERATION', message: 'Format operations require a non-empty "textToFormat" field.' }
498
- };
499
- }
500
- if (!isRecord(normalized.properties)) {
501
- return {
502
- valid: false,
503
- error: { code: 'INVALID_OPERATION', message: 'Format operations require a "properties" object.' }
504
- };
505
- }
506
- if (normalized.formattingRevisionPolicy != null && !['always', 'coalesce-own-insertion'].includes(normalized.formattingRevisionPolicy)) {
507
- return {
508
- valid: false,
509
- error: { code: 'INVALID_OPERATION', message: 'formattingRevisionPolicy must be "always" or "coalesce-own-insertion".' }
510
- };
511
- }
512
- }
513
-
514
- if (normalized.operationKind === 'paragraph-format') {
515
- if (!isRecord(normalized.properties)) {
516
- return {
517
- valid: false,
518
- error: { code: 'INVALID_OPERATION', message: 'Paragraph-format operations require a "properties" object.' }
519
- };
520
- }
521
- }
522
-
523
- if (normalized.author != null && !nonEmptyString(normalized.author)) {
524
- return {
525
- valid: false,
526
- error: { code: 'INVALID_OPERATION', message: 'Operation author must be a non-empty string when provided.' }
527
- };
528
- }
529
-
530
- return { valid: true, operation: normalized };
531
- }
532
-
533
- export function resolveDocumentOperationAuthor(operation, batchAuthor, fallbackAuthor) {
534
- if (nonEmptyString(operation?.author)) return operation.author.trim();
535
- if (nonEmptyString(batchAuthor)) return batchAuthor.trim();
536
- return nonEmptyString(fallbackAuthor) ? fallbackAuthor.trim() : 'Author';
537
- }
1
+ /**
2
+ * Public document-operation normalization and validation.
3
+ *
4
+ * Keep compatibility aliases at this boundary so the runner and preflight
5
+ * logic can operate on one internal shape.
6
+ */
7
+
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';
11
+
12
+ const SUPPORTED_OPERATION_TYPES = new Set([
13
+ 'redline',
14
+ 'replace',
15
+ 'format',
16
+ 'character-format',
17
+ 'paragraph-format',
18
+ 'list-format',
19
+ 'list-change',
20
+ 'table-reconciliation',
21
+ 'insert',
22
+ 'restore',
23
+ 'delete',
24
+ 'comment',
25
+ 'comment_reply',
26
+ 'comment_resolve',
27
+ 'highlight'
28
+ ]);
29
+
30
+ export const EXISTING_REVISIONS_POLICIES = Object.freeze([
31
+ 'merge-same-author',
32
+ 'slice-cross-author',
33
+ 'reject-input',
34
+ 'accept-all-first',
35
+ 'accept-all-first-keep-normalized'
36
+ ]);
37
+
38
+ export function isExistingRevisionsPolicy(value) {
39
+ return EXISTING_REVISIONS_POLICIES.includes(value);
40
+ }
41
+
42
+ function isRecord(value) {
43
+ return !!value && typeof value === 'object' && !Array.isArray(value);
44
+ }
45
+
46
+ function nonEmptyString(value) {
47
+ return typeof value === 'string' && value.trim().length > 0;
48
+ }
49
+
50
+ /** Operations that address an existing comment thread by id instead of targeting a paragraph. */
51
+ export function isCommentThreadOperationKind(kind) {
52
+ return kind === 'comment_reply' || kind === 'comment_resolve';
53
+ }
54
+
55
+ export function getCanonicalOperationType(operation) {
56
+ const type = operation?.type;
57
+ if (type === 'insert' && operation?.target?.revisionView === 'rejected') return 'rejected-insert';
58
+ if (type === 'restore') return 'restore';
59
+ if (type === 'comment' || type === 'comment_reply' || type === 'comment_resolve' || type === 'highlight') return type;
60
+ if (type === 'paragraph-format') return 'paragraph-format';
61
+ if (type === 'list-format') return 'list-format';
62
+ if (type === 'character-format' || (type === 'format' && (operation?.textToFormat != null || operation?.properties != null))) return 'format';
63
+ return 'redline';
64
+ }
65
+
66
+ export function normalizeTargetDescriptor(target, legacyTargetRef = null, defaultRevisionView = 'accepted') {
67
+ const fallbackRevisionView = defaultRevisionView === 'rejected' ? 'rejected' : 'accepted';
68
+ if (!isRecord(target)) {
69
+ return {
70
+ text: typeof target === 'string' ? target : '',
71
+ index: legacyTargetRef ?? null,
72
+ paragraphId: null,
73
+ occurrence: null,
74
+ inTable: null,
75
+ fingerprint: null,
76
+ revisionView: fallbackRevisionView
77
+ };
78
+ }
79
+
80
+ return {
81
+ text: typeof target.exactText === 'string'
82
+ ? target.exactText
83
+ : (typeof target.text === 'string' ? target.text : ''),
84
+ index: target.index ?? target.paragraphIndex ?? legacyTargetRef ?? null,
85
+ paragraphId: nonEmptyString(target.paragraphId) ? target.paragraphId.trim() : null,
86
+ occurrence: Number.isInteger(target.occurrence) && target.occurrence > 0
87
+ ? target.occurrence
88
+ : null,
89
+ inTable: typeof target.inTable === 'boolean' ? target.inTable : null,
90
+ fingerprint: nonEmptyString(target.fingerprint)
91
+ ? target.fingerprint.trim()
92
+ : (nonEmptyString(target.sourceFingerprint) ? target.sourceFingerprint.trim() : null),
93
+ revisionView: target.revisionView === 'rejected'
94
+ ? 'rejected'
95
+ : (target.revisionView === 'accepted' ? 'accepted' : fallbackRevisionView),
96
+ captureRef: nonEmptyString(target.captureRef) ? target.captureRef.trim() : null,
97
+ select: typeof target.select === 'string' ? target.select : null
98
+ };
99
+ }
100
+
101
+ export function normalizeDocumentOperation(operation) {
102
+ const source = isRecord(operation) ? operation : {};
103
+ const kind = getCanonicalOperationType(source);
104
+ const defaultRevisionView = kind === 'restore' ? 'rejected' : 'accepted';
105
+ const targetDescriptor = normalizeTargetDescriptor(source.target, source.targetRef, defaultRevisionView);
106
+ const targetEndDescriptor = source.targetEnd != null
107
+ ? normalizeTargetDescriptor(source.targetEnd, source.targetEndRef, defaultRevisionView)
108
+ : null;
109
+
110
+ return {
111
+ ...source,
112
+ operationId: nonEmptyString(source.operationId) ? source.operationId.trim() : null,
113
+ captureKey: nonEmptyString(source.captureKey) ? source.captureKey.trim() : null,
114
+ operationKind: kind,
115
+ anchor: isRecord(source.anchor) ? {
116
+ exactText: typeof source.anchor.exactText === 'string' ? source.anchor.exactText : '',
117
+ occurrence: Number.isInteger(source.anchor.occurrence) && source.anchor.occurrence > 0 ? source.anchor.occurrence : 1,
118
+ occurrenceExplicit: Number.isInteger(source.anchor.occurrence) && source.anchor.occurrence > 0,
119
+ offset: Number.isInteger(source.anchor.offset) ? source.anchor.offset : null
120
+ } : null,
121
+ targetDescriptor,
122
+ targetEndDescriptor,
123
+ target: targetDescriptor.text,
124
+ targetRef: targetDescriptor.index,
125
+ targetEndRef: targetEndDescriptor?.index ?? source.targetEndRef ?? null,
126
+ ...(source.type === 'delete' && source.modified == null ? { modified: '' } : {})
127
+ };
128
+ }
129
+
130
+ export function validateDocumentOperation(operation) {
131
+ if (!isRecord(operation)) {
132
+ return {
133
+ valid: false,
134
+ error: { code: 'INVALID_OPERATION', message: 'Operation must be an object.' }
135
+ };
136
+ }
137
+
138
+ const rawType = operation.type;
139
+ if (rawType != null && rawType !== '' && !SUPPORTED_OPERATION_TYPES.has(rawType)) {
140
+ return {
141
+ valid: false,
142
+ error: {
143
+ code: 'INVALID_OPERATION',
144
+ message: `Unsupported operation type: "${String(rawType)}".`
145
+ }
146
+ };
147
+ }
148
+
149
+ const normalized = normalizeDocumentOperation(operation);
150
+ if (isRecord(operation.target) && operation.target.revisionView != null) {
151
+ if (operation.target.revisionView !== 'accepted' && operation.target.revisionView !== 'rejected') {
152
+ return {
153
+ valid: false,
154
+ error: {
155
+ code: 'INVALID_OPERATION',
156
+ message: 'Target revisionView must be "accepted" or "rejected" when provided.'
157
+ }
158
+ };
159
+ }
160
+ }
161
+
162
+ if (isRecord(operation.targetEnd) && operation.targetEnd.revisionView != null) {
163
+ if (operation.targetEnd.revisionView !== 'accepted' && operation.targetEnd.revisionView !== 'rejected') {
164
+ return {
165
+ valid: false,
166
+ error: {
167
+ code: 'INVALID_OPERATION',
168
+ message: 'Target revisionView must be "accepted" or "rejected" when provided.'
169
+ }
170
+ };
171
+ }
172
+ }
173
+
174
+ if (operation.operationId != null && (!nonEmptyString(operation.operationId) || operation.operationId.trim().length > 256)) {
175
+ return {
176
+ valid: false,
177
+ error: { code: 'INVALID_OPERATION', message: 'operationId must be a non-empty string under 256 characters when provided.' }
178
+ };
179
+ }
180
+
181
+ if (operation.captureKey != null && (!nonEmptyString(operation.captureKey) || operation.captureKey.trim().length > 256)) {
182
+ return {
183
+ valid: false,
184
+ error: { code: 'INVALID_OPERATION', message: 'captureKey must be a non-empty string under 256 characters when provided.' }
185
+ };
186
+ }
187
+
188
+ if (isRecord(operation.target) && operation.target.captureRef != null && (!nonEmptyString(operation.target.captureRef) || operation.target.captureRef.trim().length > 256)) {
189
+ return {
190
+ valid: false,
191
+ error: { code: 'INVALID_OPERATION', message: 'target.captureRef must be a non-empty string under 256 characters when provided.' }
192
+ };
193
+ }
194
+
195
+ if (isRecord(operation.target) && operation.target.select != null && typeof operation.target.select !== 'string') {
196
+ return {
197
+ valid: false,
198
+ error: { code: 'INVALID_OPERATION', message: 'target.select must be a string when provided.' }
199
+ };
200
+ }
201
+
202
+ const target = normalized.targetDescriptor;
203
+ if (
204
+ !isCommentThreadOperationKind(normalized.operationKind)
205
+ && !nonEmptyString(target.text)
206
+ && target.index == null
207
+ && !target.paragraphId
208
+ && !target.fingerprint
209
+ && !target.captureRef
210
+ ) {
211
+ return {
212
+ valid: false,
213
+ error: {
214
+ code: 'INVALID_OPERATION',
215
+ message: 'Operation target must provide text, a paragraph index, a paragraphId, or a captureRef.'
216
+ }
217
+ };
218
+ }
219
+
220
+ if (target.occurrence != null && !nonEmptyString(target.text)) {
221
+ return {
222
+ valid: false,
223
+ error: {
224
+ code: 'INVALID_OPERATION',
225
+ message: 'Target occurrence requires target text.'
226
+ }
227
+ };
228
+ }
229
+
230
+ if (normalized.operationKind === 'redline') {
231
+ const hasModified = typeof normalized.modified === 'string';
232
+ const hasReplacements = normalized.replacements !== undefined;
233
+ if (hasModified === hasReplacements) {
234
+ return {
235
+ valid: false,
236
+ error: {
237
+ code: 'INVALID_OPERATION',
238
+ message: 'Redline operations require exactly one of string "modified" or non-empty "replacements".'
239
+ }
240
+ };
241
+ }
242
+ if (hasReplacements) {
243
+ if (rawType != null && rawType !== '' && !['redline', 'replace'].includes(rawType)) {
244
+ return {
245
+ valid: false,
246
+ error: {
247
+ code: 'INVALID_OPERATION',
248
+ message: 'Localized replacements are supported only for redline or replace operations.'
249
+ }
250
+ };
251
+ }
252
+ if (
253
+ target.revisionView !== 'accepted'
254
+ || target.captureRef
255
+ || target.occurrence != null
256
+ || normalized.captureKey
257
+ || normalized.targetEndDescriptor
258
+ || normalized.targetEndRef != null
259
+ ) {
260
+ return {
261
+ valid: false,
262
+ error: {
263
+ code: 'INVALID_OPERATION',
264
+ message: 'Localized replacements v1 require one batch-start accepted-view paragraph target without captures, target occurrence, or a target range.'
265
+ }
266
+ };
267
+ }
268
+ const replacementValidation = validateExactReplacementRequests(normalized.replacements);
269
+ if (!replacementValidation.ok) {
270
+ return { valid: false, error: replacementValidation.error };
271
+ }
272
+ if (replacementValidation.replacements.some(replacement => /\r|\n/.test(replacement.replace))) {
273
+ return {
274
+ valid: false,
275
+ error: {
276
+ code: 'INVALID_OPERATION',
277
+ message: 'Localized replacements v1 cannot insert paragraph breaks.'
278
+ }
279
+ };
280
+ }
281
+ // A soft line break (w:br reads as "\n") is not text the localized
282
+ // diff can delete, so a find spanning one would keep the break.
283
+ if (replacementValidation.replacements.some(replacement => /\r|\n/.test(replacement.find))) {
284
+ return {
285
+ valid: false,
286
+ error: {
287
+ code: 'INVALID_OPERATION',
288
+ message: 'Localized replacements v1 cannot span a line break; use one replacement per line or a full "modified" paragraph.'
289
+ }
290
+ };
291
+ }
292
+ normalized.replacements = replacementValidation.replacements;
293
+ }
294
+ }
295
+
296
+ if (normalized.operationKind === 'rejected-insert') {
297
+ if (!nonEmptyString(normalized.modified)) {
298
+ return {
299
+ valid: false,
300
+ error: { code: 'INVALID_OPERATION', message: 'Rejected-view insert operations require non-empty string "modified" text.' }
301
+ };
302
+ }
303
+ if (
304
+ !normalized.anchor
305
+ || !nonEmptyString(normalized.anchor.exactText)
306
+ || !Number.isInteger(normalized.anchor.offset)
307
+ || normalized.anchor.offset < 0
308
+ || normalized.anchor.offset > normalized.anchor.exactText.length
309
+ ) {
310
+ return {
311
+ valid: false,
312
+ error: {
313
+ code: 'INVALID_OPERATION',
314
+ message: 'Rejected-view insert operations require anchor.exactText, a positive occurrence, and an offset within the anchor text.'
315
+ }
316
+ };
317
+ }
318
+ if (normalized.existingRevisions !== 'slice-cross-author') {
319
+ return {
320
+ valid: false,
321
+ error: {
322
+ code: 'INVALID_OPERATION',
323
+ message: 'Rejected-view insert operations require existingRevisions: "slice-cross-author".'
324
+ }
325
+ };
326
+ }
327
+ }
328
+
329
+ if (normalized.operationKind === 'restore') {
330
+ const hasModified = operation.modified !== undefined;
331
+ const hasReplacements = operation.replacements !== undefined;
332
+ const validSingle = nonEmptyString(normalized.modified);
333
+ const validRange = Array.isArray(normalized.modified)
334
+ && normalized.modified.length > 0
335
+ && normalized.modified.every(nonEmptyString);
336
+ if (hasModified && hasReplacements) {
337
+ return {
338
+ valid: false,
339
+ error: {
340
+ code: 'INVALID_OPERATION',
341
+ message: 'Restore operations accept modified or replacements, not both.'
342
+ }
343
+ };
344
+ }
345
+ if (hasModified && !validSingle && !validRange) {
346
+ return {
347
+ valid: false,
348
+ error: {
349
+ code: 'INVALID_OPERATION',
350
+ message: 'Restore modified must be a non-empty string or non-empty string array.'
351
+ }
352
+ };
353
+ }
354
+ if (!hasModified) {
355
+ if (
356
+ target.revisionView !== 'rejected'
357
+ || (!target.paragraphId && !target.fingerprint)
358
+ || target.captureRef
359
+ || target.occurrence != null
360
+ || normalized.captureKey
361
+ || normalized.targetEndDescriptor
362
+ || normalized.targetEndRef != null
363
+ || /\r|\n/.test(target.text || '')
364
+ ) {
365
+ return {
366
+ valid: false,
367
+ error: {
368
+ code: 'INVALID_OPERATION',
369
+ message: 'Verbatim and localized restore require one strong batch-start rejected-view paragraph target without captures, target occurrence, or a target range.'
370
+ }
371
+ };
372
+ }
373
+ }
374
+ if (hasReplacements) {
375
+ const replacementValidation = validateExactReplacementRequests(normalized.replacements);
376
+ if (!replacementValidation.ok) {
377
+ return { valid: false, error: replacementValidation.error };
378
+ }
379
+ if (replacementValidation.replacements.some(replacement => /\r|\n/.test(replacement.replace))) {
380
+ return {
381
+ valid: false,
382
+ error: {
383
+ code: 'INVALID_OPERATION',
384
+ message: 'Localized restore cannot insert paragraph breaks.'
385
+ }
386
+ };
387
+ }
388
+ normalized.replacements = replacementValidation.replacements;
389
+ }
390
+ if (normalized.generateRedlines === false) {
391
+ return {
392
+ valid: false,
393
+ error: {
394
+ code: 'INVALID_OPERATION',
395
+ message: 'Restore operations require tracked changes and cannot set generateRedlines to false.'
396
+ }
397
+ };
398
+ }
399
+ }
400
+
401
+ if (normalized.structuredContent != null && typeof normalized.structuredContent !== 'boolean') {
402
+ return {
403
+ valid: false,
404
+ error: { code: 'INVALID_OPERATION', message: 'structuredContent must be a boolean when provided.' }
405
+ };
406
+ }
407
+
408
+ if (normalized.pairReplacements != null && typeof normalized.pairReplacements !== 'boolean') {
409
+ return {
410
+ valid: false,
411
+ error: { code: 'INVALID_OPERATION', message: 'pairReplacements must be a boolean when provided.' }
412
+ };
413
+ }
414
+
415
+ if (normalized.listMarkers != null && normalized.listMarkers !== 'literal') {
416
+ return {
417
+ valid: false,
418
+ error: { code: 'INVALID_OPERATION', message: 'listMarkers must be "literal" when provided.' }
419
+ };
420
+ }
421
+
422
+ if (normalized.existingRevisions != null && !isExistingRevisionsPolicy(normalized.existingRevisions)) {
423
+ return {
424
+ valid: false,
425
+ error: {
426
+ code: 'INVALID_OPERATION',
427
+ message: `existingRevisions must be one of: ${EXISTING_REVISIONS_POLICIES.join(', ')}.`
428
+ }
429
+ };
430
+ }
431
+
432
+ if (normalized.insertionAffinity != null) {
433
+ if (!isRecord(normalized.insertionAffinity)) {
434
+ return {
435
+ valid: false,
436
+ error: { code: 'INVALID_OPERATION', message: 'insertionAffinity must be an object when provided.' }
437
+ };
438
+ }
439
+ const { formatting, hyperlink, revision, bookmark, comment } = normalized.insertionAffinity;
440
+ if (formatting != null && !['left', 'right', 'none'].includes(formatting)) {
441
+ return {
442
+ valid: false,
443
+ error: { code: 'INVALID_OPERATION', message: 'insertionAffinity.formatting must be "left", "right", or "none".' }
444
+ };
445
+ }
446
+ if (hyperlink != null && !['inside', 'outside', 'preserve'].includes(hyperlink)) {
447
+ return {
448
+ valid: false,
449
+ error: { code: 'INVALID_OPERATION', message: 'insertionAffinity.hyperlink must be "inside", "outside", or "preserve".' }
450
+ };
451
+ }
452
+ if (revision != null && !['coalesce_same_author', 'separate'].includes(revision)) {
453
+ return {
454
+ valid: false,
455
+ error: { code: 'INVALID_OPERATION', message: 'insertionAffinity.revision must be "coalesce_same_author" or "separate".' }
456
+ };
457
+ }
458
+ if (bookmark != null && !['inside', 'outside'].includes(bookmark)) {
459
+ return {
460
+ valid: false,
461
+ error: { code: 'INVALID_OPERATION', message: 'insertionAffinity.bookmark must be "inside" or "outside".' }
462
+ };
463
+ }
464
+ if (comment != null && !['inside', 'outside'].includes(comment)) {
465
+ return {
466
+ valid: false,
467
+ error: { code: 'INVALID_OPERATION', message: 'insertionAffinity.comment must be "inside" or "outside".' }
468
+ };
469
+ }
470
+ }
471
+
472
+ if (normalized.operationKind === 'comment') {
473
+ if (!nonEmptyString(normalized.commentContent)) {
474
+ return {
475
+ valid: false,
476
+ error: { code: 'INVALID_OPERATION', message: 'Comment operations require a non-empty "commentContent" field.' }
477
+ };
478
+ }
479
+ }
480
+
481
+ if (normalized.operationKind === 'comment_reply') {
482
+ if (!nonEmptyString(String(normalized.parentCommentId ?? ''))) {
483
+ return { valid: false, error: { code: 'INVALID_OPERATION', message: 'Comment reply operations require a "parentCommentId".' } };
484
+ }
485
+ if (!nonEmptyString(normalized.commentContent)) {
486
+ return { valid: false, error: { code: 'INVALID_OPERATION', message: 'Comment reply operations require a non-empty "commentContent" field.' } };
487
+ }
488
+ }
489
+
490
+ if (normalized.operationKind === 'comment_resolve') {
491
+ if (!nonEmptyString(String(normalized.commentId ?? ''))) {
492
+ return { valid: false, error: { code: 'INVALID_OPERATION', message: 'Comment resolve operations require a "commentId".' } };
493
+ }
494
+ if (normalized.resolved != null && typeof normalized.resolved !== 'boolean') {
495
+ return { valid: false, error: { code: 'INVALID_OPERATION', message: 'Comment resolve "resolved" must be a boolean when provided.' } };
496
+ }
497
+ }
498
+
499
+ if (normalized.operationKind === 'highlight') {
500
+ if (!nonEmptyString(normalized.textToHighlight)) {
501
+ return {
502
+ valid: false,
503
+ error: { code: 'INVALID_OPERATION', message: 'Highlight operations require a non-empty "textToHighlight" field.' }
504
+ };
505
+ }
506
+ if (normalized.color != null && !nonEmptyString(normalized.color)) {
507
+ return {
508
+ valid: false,
509
+ error: { code: 'INVALID_OPERATION', message: 'Highlight color must be a non-empty string when provided.' }
510
+ };
511
+ }
512
+ }
513
+
514
+ if (normalized.operationKind === 'format') {
515
+ if (!nonEmptyString(normalized.textToFormat)) {
516
+ return {
517
+ valid: false,
518
+ error: { code: 'INVALID_OPERATION', message: 'Format operations require a non-empty "textToFormat" field.' }
519
+ };
520
+ }
521
+ if (normalized.textOccurrence != null && (!Number.isInteger(normalized.textOccurrence) || normalized.textOccurrence < 1)) {
522
+ return {
523
+ valid: false,
524
+ error: { code: 'INVALID_OPERATION', message: 'textOccurrence must be a positive integer when provided.' }
525
+ };
526
+ }
527
+ if (!isRecord(normalized.properties)) {
528
+ return {
529
+ valid: false,
530
+ error: { code: 'INVALID_OPERATION', message: 'Format operations require a "properties" object.' }
531
+ };
532
+ }
533
+ if (normalized.formattingRevisionPolicy != null && !['always', 'coalesce-own-insertion'].includes(normalized.formattingRevisionPolicy)) {
534
+ return {
535
+ valid: false,
536
+ error: { code: 'INVALID_OPERATION', message: 'formattingRevisionPolicy must be "always" or "coalesce-own-insertion".' }
537
+ };
538
+ }
539
+ }
540
+
541
+ if (normalized.operationKind === 'paragraph-format') {
542
+ if (!isRecord(normalized.properties)) {
543
+ return {
544
+ valid: false,
545
+ error: { code: 'INVALID_OPERATION', message: 'Paragraph-format operations require a "properties" object.' }
546
+ };
547
+ }
548
+ const unknownKeys = findUnknownParagraphFormatKeys(normalized.properties);
549
+ if (unknownKeys.length > 0) {
550
+ return {
551
+ valid: false,
552
+ error: {
553
+ code: 'INVALID_OPERATION',
554
+ message: `paragraph-format does not support ${unknownKeys.length === 1 ? 'property' : 'properties'} ${unknownKeys.map(key => `"${key}"`).join(', ')}. Supported: ${PARAGRAPH_FORMAT_PROPERTY_KEYS.join(', ')}.`,
555
+ field: `properties.${unknownKeys[0]}`,
556
+ properties: unknownKeys,
557
+ recovery: { action: 'change_request', hint: `Use only: ${PARAGRAPH_FORMAT_PROPERTY_KEYS.join(', ')}.` }
558
+ }
559
+ };
560
+ }
561
+ if (normalized.properties.numbering !== undefined) {
562
+ const shapeError = validateParagraphNumberingShape(normalized.properties.numbering);
563
+ if (shapeError) {
564
+ return {
565
+ valid: false,
566
+ error: {
567
+ code: 'INVALID_OPERATION',
568
+ message: shapeError,
569
+ field: 'properties.numbering',
570
+ recovery: { action: 'change_request', hint: 'Pass numbering as { "numId": <existing numId from inspect>, "level": 0 } or null.' }
571
+ }
572
+ };
573
+ }
574
+ }
575
+ }
576
+
577
+ if (normalized.operationKind === 'list-format') {
578
+ const shapeError = validateListFormatShape(normalized.list);
579
+ if (shapeError) {
580
+ return {
581
+ valid: false,
582
+ error: {
583
+ code: 'INVALID_OPERATION',
584
+ message: shapeError.message,
585
+ field: shapeError.field,
586
+ recovery: {
587
+ action: 'change_request',
588
+ 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).'
589
+ }
590
+ }
591
+ };
592
+ }
593
+ if (operation.modified !== undefined || operation.replacements !== undefined) {
594
+ return {
595
+ valid: false,
596
+ error: {
597
+ code: 'INVALID_OPERATION',
598
+ message: 'list-format changes list structure only; express text changes as a separate redline operation.',
599
+ field: operation.modified !== undefined ? 'modified' : 'replacements'
600
+ }
601
+ };
602
+ }
603
+ if (target.captureRef || target.occurrence != null || target.revisionView !== 'accepted') {
604
+ return {
605
+ valid: false,
606
+ error: {
607
+ code: 'INVALID_OPERATION',
608
+ message: 'list-format v1 targets batch-start accepted-view paragraphs without captures or target occurrence.'
609
+ }
610
+ };
611
+ }
612
+ }
613
+
614
+ if (normalized.author != null && !nonEmptyString(normalized.author)) {
615
+ return {
616
+ valid: false,
617
+ error: { code: 'INVALID_OPERATION', message: 'Operation author must be a non-empty string when provided.' }
618
+ };
619
+ }
620
+
621
+ return { valid: true, operation: normalized };
622
+ }
623
+
624
+ export function resolveDocumentOperationAuthor(operation, batchAuthor, fallbackAuthor) {
625
+ if (nonEmptyString(operation?.author)) return operation.author.trim();
626
+ if (nonEmptyString(batchAuthor)) return batchAuthor.trim();
627
+ return nonEmptyString(fallbackAuthor) ? fallbackAuthor.trim() : 'Author';
628
+ }