@ansonlai/docx-redline-js 0.8.4 → 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 (68) hide show
  1. package/ARCHITECTURE.md +221 -12
  2. package/CHANGELOG.md +176 -0
  3. package/README.md +125 -12
  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 +4189 -767
  9. package/dist/docx-redline-js.esm.js.map +4 -4
  10. package/dist/docx-redline-js.esm.min.js +111 -108
  11. package/dist/docx-redline-js.esm.min.js.map +4 -4
  12. package/dist/docx-redline.bundle.cjs +4292 -870
  13. package/dist/docx-redline.bundle.cjs.map +4 -4
  14. package/dist/docx-redline.bundle.js +4292 -870
  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 +19 -3
  18. package/docs/SKILL_AUTHORING.md +165 -150
  19. package/docs/TESTING.md +1142 -1059
  20. package/docs/schemas/document-operations.schema.json +54 -1
  21. package/document/docx-document.js +1022 -1010
  22. package/engine/oxml-engine.js +736 -749
  23. package/engine/paragraph-mark-revisions.js +292 -0
  24. package/engine/reconstruction-mapper.js +312 -311
  25. package/engine/reconstruction-writer.js +67 -10
  26. package/engine/rpr-helpers.js +42 -0
  27. package/engine/run-builders.js +0 -73
  28. package/engine/surgical-diff-application.js +604 -605
  29. package/engine/table-mode.js +63 -63
  30. package/index.d.ts +47 -1
  31. package/index.js +11 -2
  32. package/node/cli-help.js +1 -0
  33. package/node/cli.js +992 -975
  34. package/orchestration/list-structural-fallback.js +67 -9
  35. package/package.json +1 -1
  36. package/pipeline/diff-engine.js +130 -1
  37. package/pipeline/ingestion-export.js +227 -243
  38. package/pipeline/ingestion-paragraph.js +93 -98
  39. package/pipeline/list-generation.js +415 -419
  40. package/pipeline/pipeline.js +206 -204
  41. package/scripts/export-validation-fixtures.mjs +19 -6
  42. package/scripts/generate-word-revision-semantics-matrix.mjs +134 -0
  43. package/scripts/lib/word-coverage-catalogue.mjs +7 -2
  44. package/scripts/render-multilevel-bullet-visual.ps1 +2 -2
  45. package/scripts/word-com-corpus-visual-suite.ps1 +2 -2
  46. package/scripts/word-com-differential.ps1 +443 -335
  47. package/scripts/word-com-visual-suite.ps1 +2 -2
  48. package/scripts/word-revision-semantics-matrix.ps1 +108 -0
  49. package/services/batch-operation-orchestrator.js +23 -3
  50. package/services/capture-engine.js +228 -226
  51. package/services/document-operation-applier.js +213 -0
  52. package/services/document-operation-contract.js +75 -0
  53. package/services/document-operation-mutations.js +3346 -2721
  54. package/services/error-recovery.js +17 -0
  55. package/services/list-conversion-diagnostics.js +296 -0
  56. package/services/list-format-change.js +586 -0
  57. package/services/list-format-mutation.js +416 -0
  58. package/services/list-format.js +305 -0
  59. package/services/numbering-allocation.js +642 -0
  60. package/services/numbering-helpers.js +354 -524
  61. package/services/operation-batch-compiler.js +183 -11
  62. package/services/operation-preflight.js +1 -0
  63. package/services/receipt-collector.js +288 -288
  64. package/services/revision-comment-management.js +38 -5
  65. package/services/revision-fidelity-verifier.js +498 -0
  66. package/services/standalone-docx-plumbing.js +627 -631
  67. package/services/standalone-operation-runner.d.ts +182 -5
  68. package/services/table-reconciliation.js +59 -7
@@ -33,6 +33,21 @@ const RULES = Object.freeze({
33
33
  PATCH_ROUNDTRIP_MISMATCH: ['patch-validation', 'request_fixable', 'reinspect_and_narrow'],
34
34
  DIFF_TOKEN_LIMIT: ['patch-compilation', 'request_fixable', 'narrow_operation'],
35
35
  STRUCTURED_CONTENT_INVALID: ['request', 'request_fixable', 'change_request'],
36
+ UNSUPPORTED_LIST_CONVERSION: ['list-conversion', 'request_fixable', 'use_list_format_convert_plain'],
37
+ UNSUPPORTED_LIST_FORMAT_CHANGE: ['list-conversion', 'request_fixable', 'use_list_format_change_format'],
38
+ LIST_MARKER_NOT_FOUND: ['list-conversion', 'request_fixable', 'change_request'],
39
+ LIST_MARKER_AMBIGUOUS: ['list-conversion', 'request_fixable', 'set_list_format'],
40
+ LIST_MARKER_UNSUPPORTED: ['list-conversion', 'request_fixable', 'convert_in_host'],
41
+ LIST_FORMAT_TARGET_IS_LIST: ['list-conversion', 'request_fixable', 'use_paragraph_numbering'],
42
+ LIST_FORMAT_STYLE_NUMBERED: ['list-conversion', 'request_fixable', 'convert_in_host'],
43
+ LIST_FORMAT_TARGET_NOT_LIST: ['list-conversion', 'request_fixable', 'use_list_format_convert_plain'],
44
+ LIST_FORMAT_MIXED_RANGE: ['list-conversion', 'request_fixable', 'narrow_range'],
45
+ LIST_FORMAT_STYLE_LINKED: ['list-conversion', 'request_fixable', 'convert_in_host'],
46
+ LIST_FORMAT_SPLIT_UNSUPPORTED: ['list-conversion', 'request_fixable', 'extend_range_or_use_list_scope'],
47
+ LIST_FORMAT_DEFINITION_UNSUPPORTED: ['list-conversion', 'request_fixable', 'convert_in_host'],
48
+ LIST_FORMAT_JOIN_INVALID: ['list-conversion', 'request_fixable', 'change_request'],
49
+ PARAGRAPH_NUMBERING_NOT_FOUND: ['target-resolution', 'target_refresh_required', 'inspect_numbering'],
50
+ PARAGRAPH_FORMAT_REVISION_CONFLICT: ['target-safety', 'user_authorization_required', 'resolve_pending_revision'],
36
51
  UNSUPPORTED_INSERTION_AFFINITY: ['request', 'request_fixable', 'change_request'],
37
52
  EXISTING_REVISIONS: ['target-safety', 'policy_choice_required', 'set_option'],
38
53
  PART_NOT_FOUND: ['target-resolution', 'target_refresh_required', 'reinspect'],
@@ -46,6 +61,7 @@ const RULES = Object.freeze({
46
61
  OVERLAPPING_SOURCE_TARGETS: ['batch-compilation', 'source_conflict', 'consolidate_operations'],
47
62
  OVERLAPPING_TEXT_EDITS: ['batch-compilation', 'source_conflict', 'consolidate_operations'],
48
63
  REVISION_ORDER_CONFLICT: ['batch-compilation', 'source_conflict', 'split_or_consolidate_operations'],
64
+ LIST_FORMAT_COMPOSITION_CONFLICT: ['batch-compilation', 'source_conflict', 'split_text_and_list_changes'],
49
65
  TARGET_CONSUMED_BY_OPERATION: ['batch-execution', 'source_conflict', 'use_created_content_dependency'],
50
66
  CAPTURE_FANOUT_CONFLICT: ['batch-compilation', 'source_conflict', 'split_or_chain_capture_consumers'],
51
67
  DUPLICATE_CAPTURE_KEY: ['batch-compilation', 'request_fixable', 'rename_capture'],
@@ -54,6 +70,7 @@ const RULES = Object.freeze({
54
70
  AMBIGUOUS_CAPTURE_SELECTION: ['batch-compilation', 'candidate_selection_required', 'choose_candidate'],
55
71
  CAPTURE_STALE: ['batch-execution', 'source_conflict', 'replan_batch'],
56
72
  GENERATED_OOXML_INVALID: ['validation', 'library_or_builder_failure', 'report_library_failure'],
73
+ REVISION_FIDELITY_VIOLATION: ['verification', 'library_or_builder_failure', 'narrow_or_change_operation'],
57
74
  DOCUMENT_SERIALIZATION_FAILED: ['serialization', 'library_or_builder_failure', 'report_library_failure'],
58
75
  PACKAGE_OPERATION_FAILED: ['package', 'library_or_builder_failure', 'report_library_failure'],
59
76
  PACKAGE_VALIDATION: ['package-validation', 'library_or_builder_failure', 'report_library_failure'],
@@ -0,0 +1,296 @@
1
+ /**
2
+ * Interim list-conversion safety diagnostics (canonical list operations plan, WP-1).
3
+ *
4
+ * These detect requests that the text-reconciliation path cannot honour as list structure and
5
+ * report them instead of returning a silent `ok`:
6
+ * - `LIST_MARKER_NOT_BOUND` / `UNSUPPORTED_LIST_CONVERSION`: a plain paragraph was edited so its
7
+ * text starts with a list marker, but the marker was written as literal text (no `w:numPr`).
8
+ * - `LIST_FORMAT_NOT_APPLIED` / `UNSUPPORTED_LIST_FORMAT_CHANGE`: a list item was asked to change
9
+ * kind or numbering format, but the paragraph keeps its source numbering definition.
10
+ * - `UNKNOWN_PARAGRAPH_FORMAT_PROPERTY`: a `paragraph-format` key that is not implemented.
11
+ *
12
+ * They are independent of the Reject All verifier (`REVISION_FIDELITY_VIOLATION`). The pure
13
+ * detectors below take plain data; policy resolution decides warning versus error.
14
+ */
15
+
16
+ import { parseOoxmlSafe } from '../adapters/xml-adapter.js';
17
+ import { parseListItem } from '../pipeline/list-markers.js';
18
+ import { parseTypedListMarker } from './list-format.js';
19
+
20
+ export const LIST_MARKER_NOT_BOUND = 'LIST_MARKER_NOT_BOUND';
21
+ export const UNSUPPORTED_LIST_CONVERSION = 'UNSUPPORTED_LIST_CONVERSION';
22
+ export const LIST_FORMAT_NOT_APPLIED = 'LIST_FORMAT_NOT_APPLIED';
23
+ export const UNSUPPORTED_LIST_FORMAT_CHANGE = 'UNSUPPORTED_LIST_FORMAT_CHANGE';
24
+ export const UNKNOWN_PARAGRAPH_FORMAT_PROPERTY = 'UNKNOWN_PARAGRAPH_FORMAT_PROPERTY';
25
+ export const INVALID_PARAGRAPH_NUMBERING = 'INVALID_PARAGRAPH_NUMBERING';
26
+ export const PARAGRAPH_NUMBERING_NOT_FOUND = 'PARAGRAPH_NUMBERING_NOT_FOUND';
27
+ export const PARAGRAPH_FORMAT_REVISION_CONFLICT = 'PARAGRAPH_FORMAT_REVISION_CONFLICT';
28
+
29
+ /** Keys `paragraph-format.properties` implements today (closed set). */
30
+ export const PARAGRAPH_FORMAT_PROPERTY_KEYS = Object.freeze([
31
+ 'alignment', 'keepNext', 'keepLines', 'pageBreakBefore', 'style', 'numbering'
32
+ ]);
33
+
34
+ const NUMBERING_STYLE_TO_NUM_FMT = Object.freeze({
35
+ bullet: 'bullet',
36
+ decimal: 'decimal',
37
+ lowerAlpha: 'lowerLetter',
38
+ upperAlpha: 'upperLetter',
39
+ lowerRoman: 'lowerRoman',
40
+ upperRoman: 'upperRoman'
41
+ });
42
+ const COMPARABLE_NUM_FMTS = new Set(['bullet', 'decimal', 'lowerLetter', 'upperLetter', 'lowerRoman', 'upperRoman']);
43
+
44
+ /**
45
+ * `listMarkers: 'literal'` is the only accepted value; it suppresses these diagnostics for
46
+ * callers that intentionally want literal marker text.
47
+ */
48
+ export function isLiteralListMarkerOptOut(value) {
49
+ return value === 'literal';
50
+ }
51
+
52
+ /**
53
+ * Resolves diagnostic policy: `off` (opt-out), `error` (strict profile) or `warn` (default).
54
+ * Strict is requested explicitly with `strictListConversion` (the CLI agent profile sets it) or
55
+ * implied by an atomic batch.
56
+ */
57
+ export function resolveListDiagnosticPolicy(options = {}) {
58
+ if (isLiteralListMarkerOptOut(options.listMarkers)) return 'off';
59
+ const strict = options.strictListConversion !== undefined
60
+ ? options.strictListConversion === true
61
+ : options.atomic === true;
62
+ return strict ? 'error' : 'warn';
63
+ }
64
+
65
+ function excerpt(text, limit = 80) {
66
+ const value = String(text ?? '');
67
+ return value.length > limit ? `${value.slice(0, limit)}...` : value;
68
+ }
69
+
70
+ /** Parses single-line text as one list marker plus content, or `null`. */
71
+ export function parseLiteralListMarker(text) {
72
+ const raw = String(text ?? '');
73
+ if (!raw.trim() || /[\r\n]/.test(raw)) return null;
74
+ const parsed = parseListItem(raw, { allowZeroSpaceAfterMarker: false, indentSpaces: 2 });
75
+ if (!parsed || !parsed.marker) return null;
76
+ // A bare number or number-dot-number ("7 days", "2.1 Scope") is far more often prose than a list
77
+ // marker; require an explicit terminator for numbered markers so the diagnostics stay low-noise.
78
+ if (parsed.markerType !== 'bullet' && !/[.)]$/.test(parsed.marker)) return null;
79
+ return {
80
+ marker: parsed.marker,
81
+ kind: parsed.markerType === 'bullet' ? 'bullet' : 'numbered',
82
+ numberingStyle: parsed.numberingStyle,
83
+ numFmt: NUMBERING_STYLE_TO_NUM_FMT[parsed.numberingStyle] || 'decimal',
84
+ text: String(parsed.text || '').trim()
85
+ };
86
+ }
87
+
88
+ /**
89
+ * Detects a plain (non-list) paragraph whose edited text starts a list marker the original
90
+ * did not carry. Returns the requested marker, or `null` when there is nothing to report.
91
+ */
92
+ export function detectUnboundListMarker({ originalText, modifiedText, targetIsListItem }) {
93
+ if (targetIsListItem) return null;
94
+ const requested = parseLiteralListMarker(modifiedText);
95
+ if (!requested) return null;
96
+ const current = parseLiteralListMarker(originalText);
97
+ if (current && current.kind === requested.kind && current.numFmt === requested.numFmt) return null;
98
+ return requested;
99
+ }
100
+
101
+ /**
102
+ * The `list-format` operation that performs the conversion a text edit could not: bind the paragraph as a list
103
+ * item in the requested format starting at the typed value, leaving the (unmarked) text alone. Only offered when
104
+ * the original paragraph has no marker of its own, because `strip: "none"` would keep that marker as text.
105
+ */
106
+ function suggestedListFormatOperation(requested, originalHasMarker) {
107
+ if (originalHasMarker) return null;
108
+ const format = requested.numFmt === 'bullet' ? 'bullet' : requested.numFmt;
109
+ const list = { mode: 'convert-plain', format, strip: 'none' };
110
+ if (format !== 'bullet') {
111
+ const parsed = parseTypedListMarker(`${requested.marker} x`);
112
+ const value = parsed?.candidates?.find(candidate => candidate.format === format)?.value;
113
+ if (Number.isInteger(value) && value > 1) list.start = value;
114
+ }
115
+ return { type: 'list-format', list };
116
+ }
117
+
118
+ /** Builds the structured diagnostic (warning or error payload) for an unbound marker. */
119
+ export function buildUnboundListMarkerDiagnostic(requested, { policy, targetText, operationIndex } = {}) {
120
+ const isError = policy === 'error';
121
+ const code = isError ? UNSUPPORTED_LIST_CONVERSION : LIST_MARKER_NOT_BOUND;
122
+ const message = isError
123
+ ? `Converting a plain paragraph to a list item is not supported by text edits: the new text begins with the list marker "${requested.marker}", which would be written as literal text with no list binding. Use a list-format convert-plain operation on the paragraph (capability list-format-v1), or pass listMarkers: "literal" to keep the marker as text.`
124
+ : `Modified text begins with the list marker "${requested.marker}", but the marker was written as literal text; no list binding was created. A list-format convert-plain operation creates the list binding.`;
125
+ const suggestedOperation = suggestedListFormatOperation(requested, !!parseLiteralListMarker(targetText));
126
+ return {
127
+ code,
128
+ severity: isError ? 'error' : 'warning',
129
+ message,
130
+ listMarker: requested.marker,
131
+ requestedList: { kind: requested.kind, format: requested.numFmt },
132
+ ...(targetText ? { targetExcerpt: excerpt(targetText) } : {}),
133
+ ...(Number.isInteger(operationIndex) ? { operationIndex } : {}),
134
+ recovery: {
135
+ action: 'use_list_format_convert_plain',
136
+ hint: suggestedOperation
137
+ ? 'Keep the paragraph text as it is and send a list-format operation on the same target (recovery.suggestedOperation plus your target), or pass listMarkers: "literal" to write the marker as text intentionally.'
138
+ : 'Edit the text with a redline that starts with the typed marker, then convert it with a list-format convert-plain operation (strip: "marker"), or pass listMarkers: "literal" to write the marker as text intentionally.',
139
+ ...(suggestedOperation ? { suggestedOperation } : {}),
140
+ field: 'listMarkers',
141
+ allowedValue: 'literal'
142
+ }
143
+ };
144
+ }
145
+
146
+ function levelDefinition(numberingDoc, numId, ilvl) {
147
+ const byTag = tag => Array.from(numberingDoc.getElementsByTagNameNS('*', tag));
148
+ const attr = (node, name) => node?.getAttribute?.(`w:${name}`) ?? node?.getAttribute?.(name) ?? null;
149
+ const child = (node, tag) => Array.from(node?.childNodes || []).find(c => c.nodeType === 1 && c.localName === tag) || null;
150
+ const num = byTag('num').find(node => String(attr(node, 'numId')) === String(numId));
151
+ if (!num) return null;
152
+ const override = Array.from(num.childNodes).filter(c => c.nodeType === 1 && c.localName === 'lvlOverride')
153
+ .find(c => Number(attr(c, 'ilvl') || 0) === ilvl);
154
+ const overrideFmt = attr(child(child(override, 'lvl'), 'numFmt'), 'val');
155
+ if (overrideFmt) return { numFmt: overrideFmt };
156
+ const abstractId = attr(child(num, 'abstractNumId'), 'val');
157
+ const abstract = byTag('abstractNum').find(node => String(attr(node, 'abstractNumId')) === String(abstractId));
158
+ if (!abstract) return null;
159
+ const lvl = Array.from(abstract.childNodes).filter(c => c.nodeType === 1 && c.localName === 'lvl')
160
+ .find(c => Number(attr(c, 'ilvl') || 0) === ilvl);
161
+ if (!lvl) return null;
162
+ return { numFmt: attr(child(lvl, 'numFmt'), 'val') || 'decimal' };
163
+ }
164
+
165
+ /** Reads `{ numFmt, kind }` of a numbering level from the source numbering part, or `null`. */
166
+ export function readSourceListLevel(sourceNumberingXml, numId, ilvl = 0) {
167
+ if (!String(sourceNumberingXml || '').trim() || numId == null) return null;
168
+ const parsed = parseOoxmlSafe(String(sourceNumberingXml));
169
+ if (!parsed?.doc) return null;
170
+ const level = levelDefinition(parsed.doc, numId, Number(ilvl) || 0);
171
+ if (!level) return null;
172
+ return { numFmt: level.numFmt, kind: level.numFmt === 'bullet' ? 'bullet' : 'numbered' };
173
+ }
174
+
175
+ /**
176
+ * Compares the format a list edit asked for with the source level definition. Returns the
177
+ * mismatch description or `null` when it matches, is unknown, or cannot be compared safely.
178
+ */
179
+ export function detectListFormatMismatch({ requested, sourceLevel }) {
180
+ if (!requested || !sourceLevel) return null;
181
+ const requestedKind = requested.kind === 'bullet' ? 'bullet' : 'numbered';
182
+ if (requestedKind !== sourceLevel.kind) return { requested, source: sourceLevel };
183
+ if (
184
+ requestedKind === 'numbered'
185
+ && COMPARABLE_NUM_FMTS.has(requested.numFmt)
186
+ && COMPARABLE_NUM_FMTS.has(sourceLevel.numFmt)
187
+ && requested.numFmt !== sourceLevel.numFmt
188
+ ) {
189
+ return { requested, source: sourceLevel };
190
+ }
191
+ return null;
192
+ }
193
+
194
+ export function buildListFormatDiagnostic(mismatch, { policy, numId, level, marker, targetText, operationIndex } = {}) {
195
+ const isError = policy === 'error';
196
+ const code = isError ? UNSUPPORTED_LIST_FORMAT_CHANGE : LIST_FORMAT_NOT_APPLIED;
197
+ const describe = (kind, format) => (kind === 'bullet' ? 'a bullet list' : `a numbered list${format ? ` (${format})` : ''}`);
198
+ const requestedFormat = mismatch.requested.kind === 'bullet' ? 'bullet' : (mismatch.requested.numFmt || mismatch.requested.format);
199
+ const requestedText = describe(mismatch.requested.kind, requestedFormat);
200
+ const sourceText = describe(mismatch.source.kind, mismatch.source.numFmt);
201
+ const suggestedOperation = COMPARABLE_NUM_FMTS.has(requestedFormat)
202
+ ? { type: 'list-format', list: { mode: 'change-format', format: requestedFormat, level: Number(level) || 0, scope: 'list' } }
203
+ : null;
204
+ return {
205
+ code,
206
+ severity: isError ? 'error' : 'warning',
207
+ message: `${isError ? 'Changing the list format is not supported by text edits' : 'The requested list format was not applied'}: the list item keeps its source numbering definition (numId ${numId}, ${sourceText}); the edit asked for ${requestedText}${marker ? ` ("${marker}")` : ''}. A list-format change-format operation changes the list's format (capability list-format-change-v1).`,
208
+ requestedList: { kind: mismatch.requested.kind, format: requestedFormat },
209
+ sourceList: { numId: String(numId), level: Number(level) || 0, kind: mismatch.source.kind, format: mismatch.source.numFmt },
210
+ ...(targetText ? { targetExcerpt: excerpt(targetText) } : {}),
211
+ ...(Number.isInteger(operationIndex) ? { operationIndex } : {}),
212
+ recovery: {
213
+ action: 'use_list_format_change_format',
214
+ hint: suggestedOperation
215
+ ? 'Text edits cannot change a list\'s kind or numbering format. Send recovery.suggestedOperation with a target on one item of the list as its own operation (scope "list" rebinds every item; scope "range" only a range that runs to the end of the list). Make any text edit in a separate redline without the new markers, targeting a different paragraph or a separate call. Or pass listMarkers: "literal" to accept the current result.'
216
+ : 'Text edits cannot change a list\'s kind or numbering format. Use a list-format change-format operation, or pass listMarkers: "literal" to accept the current result.',
217
+ ...(suggestedOperation ? { suggestedOperation } : {}),
218
+ field: 'listMarkers',
219
+ allowedValue: 'literal'
220
+ }
221
+ };
222
+ }
223
+
224
+ /**
225
+ * Validates the shape of `paragraph-format.properties.numbering`: `null` (remove the binding) or
226
+ * `{ numId: positive integer, level?: integer 0-8 }`. Returns an error message or null.
227
+ */
228
+ export function validateParagraphNumberingShape(numbering) {
229
+ if (numbering === null) return null;
230
+ if (typeof numbering !== 'object' || Array.isArray(numbering)) {
231
+ return 'numbering must be null (remove the list binding) or an object { numId, level }.';
232
+ }
233
+ const extra = Object.keys(numbering).filter(key => key !== 'numId' && key !== 'level');
234
+ if (extra.length > 0) return `numbering has unsupported ${extra.length === 1 ? 'key' : 'keys'}: ${extra.join(', ')}. Use only numId and level.`;
235
+ const numId = numbering.numId;
236
+ const numIdOk = (Number.isInteger(numId) && numId >= 1) || (typeof numId === 'string' && /^[1-9]\d*$/.test(numId));
237
+ if (!numIdOk) return 'numbering.numId must be a positive integer (numId 0 is not a list; pass numbering: null to remove the binding).';
238
+ const level = numbering.level;
239
+ if (level !== undefined && !(Number.isInteger(level) && level >= 0 && level <= 8)) {
240
+ return 'numbering.level must be an integer from 0 to 8.';
241
+ }
242
+ return null;
243
+ }
244
+
245
+ /** Finds `paragraph-format.properties` keys that are not implemented. */
246
+ export function findUnknownParagraphFormatKeys(properties) {
247
+ if (!properties || typeof properties !== 'object') return [];
248
+ return Object.keys(properties).filter(key => !PARAGRAPH_FORMAT_PROPERTY_KEYS.includes(key));
249
+ }
250
+
251
+ export function buildUnknownParagraphFormatDiagnostic(keys) {
252
+ const list = keys.map(key => `"${key}"`).join(', ');
253
+ return {
254
+ code: UNKNOWN_PARAGRAPH_FORMAT_PROPERTY,
255
+ severity: 'warning',
256
+ message: `paragraph-format ignored unsupported ${keys.length === 1 ? 'property' : 'properties'} ${list}. Supported: ${PARAGRAPH_FORMAT_PROPERTY_KEYS.join(', ')}.`,
257
+ properties: keys,
258
+ recovery: { action: 'change_request', hint: `Use only: ${PARAGRAPH_FORMAT_PROPERTY_KEYS.join(', ')}.` }
259
+ };
260
+ }
261
+
262
+ /** `CODE: message` string form used in `warnings` arrays. */
263
+ export function diagnosticWarningText(diagnostic) {
264
+ return `${diagnostic.code}: ${diagnostic.message}`;
265
+ }
266
+
267
+ function normalizeLine(text) {
268
+ return String(text ?? '').replace(/[\s\u00a0]+/g, ' ').trim();
269
+ }
270
+
271
+ /**
272
+ * Compares the list markers in a multi-line edit with the list items it edits.
273
+ *
274
+ * `sourceItems` are `{ text, numId, ilvl }` for the existing list paragraphs. A modified line
275
+ * that keeps a source item's text and carries a marker states the format requested for that
276
+ * item. Inserted marker lines are not compared: the insertion heuristics deliberately add
277
+ * nested bullets under numbered items and the reverse.
278
+ */
279
+ export function detectRequestedListFormatMismatch({ modifiedText, sourceItems, sourceNumberingXml }) {
280
+ if (!Array.isArray(sourceItems) || sourceItems.length === 0) return null;
281
+ const lines = String(modifiedText ?? '').split(/\r?\n/).filter(line => line.trim());
282
+ const parsed = lines.map(line => ({ line, item: parseLiteralListMarker(line) }));
283
+ const retained = new Set();
284
+ for (const source of sourceItems) {
285
+ const sourceText = normalizeLine(source.text);
286
+ const match = parsed.find((entry, index) => !retained.has(index)
287
+ && normalizeLine(entry.item ? entry.item.text : entry.line) === sourceText);
288
+ if (!match) continue;
289
+ retained.add(parsed.indexOf(match));
290
+ if (!match.item) continue;
291
+ const sourceLevel = readSourceListLevel(sourceNumberingXml, source.numId, source.ilvl);
292
+ const mismatch = detectListFormatMismatch({ requested: match.item, sourceLevel });
293
+ if (mismatch) return { mismatch, marker: match.item.marker, numId: source.numId, level: source.ilvl, targetText: source.text };
294
+ }
295
+ return null;
296
+ }