@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
@@ -0,0 +1,498 @@
1
+ import { getAllReferencedNumIdElements } from '../core/current-properties.js';
2
+ import { projectWordRevisionView } from '../core/word-revision-semantics.js';
3
+
4
+ /**
5
+ * Post-apply revision fidelity invariant.
6
+ *
7
+ * A tracked operation only adds revisions on top of the source, so Word's Reject All of
8
+ * the output must equal Word's Reject All of the input: same paragraphs in the same
9
+ * order, same text (breaks and tabs included), same properties and numbering, and the
10
+ * same inline wrapper membership (hyperlinks, simple fields, content controls). Both
11
+ * sides reject every author's revisions, which keeps other reviewers' pre-existing
12
+ * revisions comparable without filtering by revision id.
13
+ *
14
+ * Works on live DOMs and never serializes.
15
+ */
16
+
17
+ export const REVISION_FIDELITY_MODES = Object.freeze(['enforce', 'warn', 'off']);
18
+ export const DEFAULT_REVISION_FIDELITY_MODE = 'enforce';
19
+ const MAX_REPORTED_VIOLATIONS = 5;
20
+ const EXCERPT_LENGTH = 120;
21
+
22
+ /**
23
+ * Per-invariant explanation and retry guidance. Agents read `explanation` to understand the
24
+ * failure and `retry` to choose a different operation; the same arguments always fail again.
25
+ */
26
+ export const REVISION_FIDELITY_INVARIANTS = Object.freeze({
27
+ REJECT_PARAGRAPH_COUNT: {
28
+ explanation: 'Reject All of the output has a different number of paragraphs than the source: a paragraph was created or removed without a tracked paragraph mark.',
29
+ retry: 'Target one paragraph at a time; add new paragraphs with a separate insertion instead of folding them into a replacement; do not merge or split paragraphs in the same operation as a text edit.'
30
+ },
31
+ REJECT_PARAGRAPH_TEXT: {
32
+ explanation: 'Reject All of the output shows different paragraph text than the source: text, a tab or a manual line break changed without being tracked, or was reordered.',
33
+ retry: 'Use replacements (find/replace) for a small literal change, keep manual line breaks and tabs where they are, and retry with the exact current paragraph text from a fresh extraction.'
34
+ },
35
+ REJECT_PARAGRAPH_PROPS: {
36
+ explanation: 'Reject All of the output shows different paragraph properties (style, alignment, spacing, indentation) than the source: a property changed without a tracked w:pPrChange.',
37
+ retry: 'Change paragraph formatting with a separate paragraph-format operation and keep the text edit free of style or list changes.'
38
+ },
39
+ REJECT_NUMBERING_REFS: {
40
+ explanation: 'Reject All of the output puts a source paragraph in a different list or list level (numId/ilvl) than the source, so the restored document would number differently.',
41
+ retry: 'Edit list item text without changing list markers or indentation, and make list membership or level changes in their own operation.'
42
+ },
43
+ ACCEPT_NEIGHBORS: {
44
+ explanation: 'An untracked edit changed a paragraph outside its target, so the change could not be reviewed or undone.',
45
+ retry: 'Re-extract the target and retry against exactly that paragraph; if the edit must span several paragraphs, use a tracked operation with an explicit targetEnd range.'
46
+ },
47
+ UNTRACKED_CREATED_REVISION: {
48
+ explanation: 'An untracked edit (generateRedlines: false) created a tracked revision (w:ins, w:del, a move, a property change or a paragraph-mark revision) that was not in the source, so the edit is not the plain change the caller asked for.',
49
+ retry: 'Retry the same edit against the current extraction; if it is refused again the untracked path wrote a tracked revision, so report the operation instead of working around it, or make the edit tracked (generateRedlines: true) if a reviewable change is acceptable.'
50
+ },
51
+ ACCEPT_INTENT: {
52
+ explanation: 'Accept All of the target of the operation would not read as the complete replacement text the operation declared: content was lost, duplicated or left out of the accepted result.',
53
+ retry: 'Re-extract the target paragraph(s) and retry with the exact current text as the basis; for a small literal change use replacements instead of complete modified text, and keep list, table and Markdown structure out of a plain-text edit.'
54
+ },
55
+ REJECT_WRAPPERS: {
56
+ explanation: 'Reject All of the output moves text into or out of a hyperlink, field or content control compared with the source.',
57
+ retry: 'Keep the change entirely inside or entirely outside the hyperlink, field or content control, for example with replacements that do not cross its boundary.'
58
+ }
59
+ });
60
+
61
+ function excerpt(value) {
62
+ if (value == null) return null;
63
+ const text = typeof value === 'string' ? value : JSON.stringify(value);
64
+ return text.length > EXCERPT_LENGTH ? `${text.slice(0, EXCERPT_LENGTH - 3)}...` : text;
65
+ }
66
+
67
+ function describeRecord(record) {
68
+ return record ? {
69
+ sourceParagraphIndex: record.sourceIndex + 1,
70
+ container: record.container,
71
+ text: excerpt(record.text)
72
+ } : null;
73
+ }
74
+
75
+ /**
76
+ * Session-level memo of one document's derived views. `document` identifies the DOM it was computed on;
77
+ * `accepted`/`rejected` are projections, `revisionSignatures` the revision-id to signature map and `numberingRefs` the
78
+ * numbering references. Fields fill lazily so a verifier computes each at most once per DOM, and the
79
+ * after-state's entry becomes the next operation's baseline.
80
+ */
81
+ export function createFidelityViews(document) {
82
+ return { document, accepted: null, rejected: null, revisionSignatures: null, numberingRefs: null };
83
+ }
84
+
85
+ function memoView(views, documentNode, view) {
86
+ if (!views) return projectWordRevisionView(documentNode, view);
87
+ return views[view] || (views[view] = projectWordRevisionView(documentNode, view));
88
+ }
89
+
90
+ /** Projects the Reject All view of a document DOM; exposed so callers can cache it. */
91
+ export function projectRejectedView(documentNode) {
92
+ return projectWordRevisionView(documentNode, 'rejected');
93
+ }
94
+
95
+ /**
96
+ * Compares two Reject All projections.
97
+ *
98
+ * @param {Object} input
99
+ * @param {Array|Document} input.before - savepoint DOM or its cached projection
100
+ * @param {Array|Document} input.after - post-mutation DOM or its projection
101
+ * @param {'rejected'|'accepted'} [input.baselineView='rejected'] - 'accepted' for policies that
102
+ * accept existing revisions in the target before editing: the changed region is compared with
103
+ * the source's accepted view, everything else with its rejected view
104
+ * @returns {{ ok: boolean, violations?: Array<{ invariant: string, paragraph: number, expected: string|null,
105
+ * actual: string|null, explanation: string, source: Object|null, output: Object|null }>,
106
+ * afterProjection: Array }} `afterProjection` lets a session reuse it as the next operation's baseline
107
+ */
108
+ export function verifyRevisionFidelity({ before, after, baselineView = 'rejected', acceptedScope = null, beforeViews = null, afterViews = null }) {
109
+ const actual = Array.isArray(after) ? after : memoView(afterViews, after, 'rejected');
110
+ const rejectedBefore = Array.isArray(before) ? before : memoView(beforeViews, before, 'rejected');
111
+ const expected = baselineView === 'accepted' && !Array.isArray(before)
112
+ ? spliceAcceptedRegion(rejectedBefore, memoView(beforeViews, before, 'accepted'), actual, acceptedScope)
113
+ : rejectedBefore;
114
+ const violations = [];
115
+ const push = (invariant, paragraph, expectedValue, actualValue, sourceRecord, outputRecord) => {
116
+ violations.push({
117
+ invariant,
118
+ paragraph,
119
+ expected: excerpt(expectedValue),
120
+ actual: excerpt(actualValue),
121
+ explanation: REVISION_FIDELITY_INVARIANTS[invariant].explanation,
122
+ source: describeRecord(sourceRecord),
123
+ output: describeRecord(outputRecord)
124
+ });
125
+ };
126
+
127
+ if (expected.length !== actual.length) {
128
+ let first = 0;
129
+ while (first < expected.length && first < actual.length && sameRecord(expected[first], actual[first])) first++;
130
+ push('REJECT_PARAGRAPH_COUNT', first + 1, `${expected.length} paragraphs`, `${actual.length} paragraphs`,
131
+ expected[first] || expected[expected.length - 1], actual[first] || actual[actual.length - 1]);
132
+ return { ok: false, violations, afterProjection: actual };
133
+ }
134
+
135
+ for (let i = 0; i < expected.length && violations.length < MAX_REPORTED_VIOLATIONS; i++) {
136
+ const source = expected[i];
137
+ const output = actual[i];
138
+ const paragraph = i + 1;
139
+ if (source.container !== output.container) {
140
+ push('REJECT_PARAGRAPH_COUNT', paragraph, `paragraph in ${source.container}`, `paragraph in ${output.container}`, source, output);
141
+ } else if (source.text !== output.text) {
142
+ push('REJECT_PARAGRAPH_TEXT', paragraph, source.text, output.text, source, output);
143
+ } else if ((source.numPr?.numId ?? null) !== (output.numPr?.numId ?? null)
144
+ || (source.numPr?.ilvl ?? null) !== (output.numPr?.ilvl ?? null)) {
145
+ push('REJECT_NUMBERING_REFS', paragraph, source.numPr, output.numPr, source, output);
146
+ } else if (source.props !== output.props) {
147
+ push('REJECT_PARAGRAPH_PROPS', paragraph, source.props, output.props, source, output);
148
+ } else if (source.wrappers.join('|') !== output.wrappers.join('|')) {
149
+ push('REJECT_WRAPPERS', paragraph, source.wrappers, output.wrappers, source, output);
150
+ }
151
+ }
152
+ return violations.length
153
+ ? { ok: false, violations, afterProjection: actual }
154
+ : { ok: true, afterProjection: actual };
155
+ }
156
+
157
+ const sameRecord = (a, b) => a.text === b.text && a.props === b.props && a.container === b.container
158
+ && (a.numPr?.numId ?? null) === (b.numPr?.numId ?? null) && (a.numPr?.ilvl ?? null) === (b.numPr?.ilvl ?? null)
159
+ && a.wrappers.join('|') === b.wrappers.join('|');
160
+
161
+ /**
162
+ * Untracked operations (generateRedlines: false) have no Reject All to protect, so their
163
+ * guarantee is locality: every paragraph before and after the single resolved target is
164
+ * unchanged in both the Accept All and Reject All views.
165
+ *
166
+ * @param {Object} input
167
+ * @param {Document} input.before - savepoint DOM
168
+ * @param {Document} input.after - post-mutation DOM
169
+ * @param {number} input.targetSourceIndex - zero-based body paragraph position of the target in `before`
170
+ */
171
+ export function verifyUntrackedLocality({ before, after, targetSourceIndex, beforeViews = null, afterViews = null }) {
172
+ const violations = [];
173
+ for (const view of ['accepted', 'rejected']) {
174
+ const expected = memoView(beforeViews, before, view);
175
+ const actual = memoView(afterViews, after, view);
176
+ const targetPosition = expected.findIndex(record => record.sourceIndex >= targetSourceIndex);
177
+ if (targetPosition < 0) continue;
178
+ const needSuffix = expected.length - targetPosition - 1;
179
+ let prefix = 0;
180
+ while (prefix < targetPosition && prefix < actual.length && sameRecord(expected[prefix], actual[prefix])) prefix++;
181
+ let suffix = 0;
182
+ while (suffix < needSuffix && suffix < actual.length - prefix
183
+ && sameRecord(expected[expected.length - 1 - suffix], actual[actual.length - 1 - suffix])) suffix++;
184
+ if (prefix < targetPosition) {
185
+ violations.push(neighborViolation(prefix + 1, expected[prefix], actual[prefix], view));
186
+ } else if (suffix < needSuffix) {
187
+ const index = expected.length - 1 - suffix;
188
+ violations.push(neighborViolation(index + 1, expected[index], actual[actual.length - 1 - suffix], view));
189
+ }
190
+ if (violations.length) break;
191
+ }
192
+ return violations.length ? { ok: false, violations } : { ok: true };
193
+ }
194
+
195
+ const numberingDefinitionCache = new Map();
196
+ const NUMBERING_DEFINITION_CACHE_SIZE = 8;
197
+
198
+ /** Parses one numbering part's abstractNum levels and num -> abstractNum links; memoized by the part text. */
199
+ function parseNumberingDefinitions(text) {
200
+ const cached = numberingDefinitionCache.get(text);
201
+ if (cached) return cached;
202
+ const abstractLevels = new Map();
203
+ for (const match of text.matchAll(/<(?:\w+:)?abstractNum([^>]*)>([\s\S]*?)<\/(?:\w+:)?abstractNum>/g)) {
204
+ const id = /abstractNumId="([^"]*)"/.exec(match[1])?.[1];
205
+ if (id == null) continue;
206
+ const levels = abstractLevels.get(id) || new Set();
207
+ for (const lvl of match[2].matchAll(/<(?:\w+:)?lvl[^>]*?ilvl="([^"]*)"/g)) levels.add(lvl[1]);
208
+ abstractLevels.set(id, levels);
209
+ }
210
+ const numToAbstract = new Map();
211
+ for (const match of text.matchAll(/<(?:\w+:)?num([^>]*)>([\s\S]*?)<\/(?:\w+:)?num>/g)) {
212
+ const id = /numId="([^"]*)"/.exec(match[1])?.[1];
213
+ const abstractId = /abstractNumId[^>]*?val="([^"]*)"/.exec(match[2])?.[1];
214
+ if (id != null && abstractId != null) numToAbstract.set(id, abstractId);
215
+ }
216
+ const definitions = { abstractLevels, numToAbstract };
217
+ if (numberingDefinitionCache.size >= NUMBERING_DEFINITION_CACHE_SIZE) numberingDefinitionCache.delete(numberingDefinitionCache.keys().next().value);
218
+ numberingDefinitionCache.set(text, definitions);
219
+ return definitions;
220
+ }
221
+
222
+ /**
223
+ * Numbering-part existence (REJECT_NUMBERING_REFS, part 2). Every numId the output binds or that
224
+ * Reject All would restore (current numPr and the historical numPr inside w:pPrChange) and that the
225
+ * source did not already reference must resolve to a w:num -> w:abstractNum -> w:lvl in the numbering
226
+ * parts available at the seam (source numbering plus parts generated in the session and by this operation).
227
+ * Ids the source already used are the source's responsibility and are not re-checked.
228
+ *
229
+ * @param {Object} input
230
+ * @param {Document} input.before - savepoint DOM
231
+ * @param {Document} input.after - post-mutation DOM
232
+ * @param {Array<string|null|undefined>} input.numberingXmls - numbering part texts available at the seam
233
+ */
234
+ export function verifyNumberingReferences({ before, after, numberingXmls, beforeViews = null, afterViews = null }) {
235
+ const references = documentNode => {
236
+ const found = new Map();
237
+ for (const numIdNode of getAllReferencedNumIdElements(documentNode)) {
238
+ const numId = numIdNode.getAttribute('w:val');
239
+ if (numId === null || numId === '' || numId === '0') continue;
240
+ const ilvlNode = Array.from(numIdNode.parentNode?.childNodes || []).find(child => child.nodeType === 1 && nameOf(child) === 'ilvl');
241
+ const ilvl = ilvlNode?.getAttribute('w:val') || '0';
242
+ if (!found.has(numId)) found.set(numId, new Set());
243
+ found.get(numId).add(ilvl);
244
+ }
245
+ return found;
246
+ };
247
+ const used = references(after);
248
+ if (used.size === 0) return { ok: true };
249
+ const known = references(before);
250
+ const text = (numberingXmls || []).filter(Boolean).join('\n');
251
+ const abstractLevels = new Map();
252
+ for (const match of text.matchAll(/<(?:\w+:)?abstractNum\b([^>]*)>([\s\S]*?)<\/(?:\w+:)?abstractNum>/g)) {
253
+ const id = /abstractNumId="([^"]*)"/.exec(match[1])?.[1];
254
+ if (id == null) continue;
255
+ const levels = abstractLevels.get(id) || new Set();
256
+ for (const lvl of match[2].matchAll(/<(?:\w+:)?lvl\b[^>]*?ilvl="([^"]*)"/g)) levels.add(lvl[1]);
257
+ abstractLevels.set(id, levels);
258
+ }
259
+ const numToAbstract = new Map();
260
+ for (const match of text.matchAll(/<(?:\w+:)?num\b([^>]*)>([\s\S]*?)<\/(?:\w+:)?num>/g)) {
261
+ const id = /numId="([^"]*)"/.exec(match[1])?.[1];
262
+ const abstractId = /abstractNumId[^>]*?val="([^"]*)"/.exec(match[2])?.[1];
263
+ if (id != null && abstractId != null) numToAbstract.set(id, abstractId);
264
+ }
265
+ const violations = [];
266
+ for (const [numId, levels] of used) {
267
+ if (known.has(numId)) continue;
268
+ const abstractId = numToAbstract.get(numId);
269
+ const available = abstractId != null ? abstractLevels.get(abstractId) : null;
270
+ let actual = null;
271
+ if (abstractId == null) actual = `numId ${numId} has no w:num in the output numbering`;
272
+ else if (!available) actual = `numId ${numId} points at missing w:abstractNum ${abstractId}`;
273
+ else {
274
+ const missing = [...levels].find(level => !available.has(level));
275
+ if (missing !== undefined) actual = `numId ${numId} level ${missing} is not defined by w:abstractNum ${abstractId}`;
276
+ }
277
+ if (actual === null) continue;
278
+ violations.push({
279
+ invariant: 'REJECT_NUMBERING_REFS',
280
+ paragraph: null,
281
+ expected: `numId ${numId} defined in the output numbering`,
282
+ actual,
283
+ explanation: 'The output (or what Reject All restores) references a list numId that no numbering definition in the output provides, so Word would drop or renumber the list.',
284
+ source: null,
285
+ output: null
286
+ });
287
+ if (violations.length >= MAX_REPORTED_VIOLATIONS) break;
288
+ }
289
+ return violations.length ? { ok: false, violations } : { ok: true };
290
+ }
291
+
292
+ const STRUCTURE_CHARACTERS = /\+\+|==|[*_#>`|~\[\]\\]/;
293
+ const LIST_MARKER_LINE = /^\s*(?:[-+•●]|[A-Za-z0-9]{1,5}[.)]|\d[0-9.]*[.)]?|\(\w{1,4}\))\s/m;
294
+
295
+ /** True when `modified` is plain text whose Accept All result is derivable verbatim (no Markdown, list or table syntax). */
296
+ export function isPlainTextIntent(modified) {
297
+ return typeof modified === 'string' && modified.length > 0
298
+ && !STRUCTURE_CHARACTERS.test(modified) && !LIST_MARKER_LINE.test(modified);
299
+ }
300
+
301
+ /**
302
+ * ACCEPT_INTENT: Accept All of a plain-text redline's target range must equal the declared complete
303
+ * `modified` text (accepted-view paragraph texts joined by a newline). The range is located in the
304
+ * accepted view of the output by the untouched paragraphs before and after it.
305
+ *
306
+ * @param {Object} input
307
+ * @param {Document} input.before - savepoint DOM
308
+ * @param {Document} input.after - post-mutation DOM
309
+ * @param {{ from: number, to: number }} input.range - zero-based document positions of the target range in `before`
310
+ * @param {string} input.modified - declared complete replacement text
311
+ */
312
+ export function verifyAcceptIntent({ before, after, range, modified, beforeViews = null, afterViews = null }) {
313
+ const sourceView = memoView(beforeViews, before, 'accepted');
314
+ const outputView = memoView(afterViews, after, 'accepted');
315
+ const prefix = sourceView.filter(record => record.sourceIndex < range.from).length;
316
+ const suffix = sourceView.filter(record => record.sourceIndex > range.to).length;
317
+ const targetRecords = sourceView.filter(record => record.sourceIndex >= range.from && record.sourceIndex <= range.to);
318
+ // Skip when the target is not a plain run of body paragraphs in the accepted view (a foreign deleted
319
+ // paragraph mark merges paragraphs there, so the target cannot be located by position).
320
+ if (targetRecords.length !== range.to - range.from + 1 || targetRecords.some(record => record.container !== 'body')) {
321
+ return { ok: true, skipped: 'target not positionally comparable' };
322
+ }
323
+ const region = outputView.slice(prefix, Math.max(prefix, outputView.length - suffix));
324
+ const actual = region.map(record => record.text).join('\n');
325
+ // Paragraph boundaries may differ (a list adjacency heuristic splits a concatenated edit into items);
326
+ // the text itself must be exactly what was declared.
327
+ const flat = value => value.replace(/\r?\n/g, '');
328
+ if (actual === modified || flat(actual) === flat(modified)) return { ok: true };
329
+ return {
330
+ ok: false,
331
+ violations: [{
332
+ invariant: 'ACCEPT_INTENT',
333
+ paragraph: null,
334
+ expected: modified,
335
+ actual,
336
+ explanation: REVISION_FIDELITY_INVARIANTS.ACCEPT_INTENT.explanation,
337
+ source: targetRecords[0] ? describeRecord(targetRecords[0]) : null,
338
+ output: region[0] ? describeRecord(region[0]) : null
339
+ }]
340
+ };
341
+ }
342
+
343
+ const REVISION_ELEMENTS = new Set([
344
+ 'ins', 'del', 'moveFrom', 'moveTo', 'rPrChange', 'pPrChange', 'sectPrChange', 'tblPrChange',
345
+ 'tblGridChange', 'tblPrExChange', 'trPrChange', 'tcPrChange', 'numberingChange',
346
+ 'cellIns', 'cellDel', 'cellMerge'
347
+ ]);
348
+
349
+ const nameOf = node => node.localName || String(node.nodeName).replace(/^.*:/, '');
350
+
351
+ function collectRevisionIds(documentNode) {
352
+ const ids = new Map();
353
+ const stack = [documentNode];
354
+ while (stack.length) {
355
+ const node = stack.pop();
356
+ if (node.nodeType === 1 && REVISION_ELEMENTS.has(nameOf(node))) {
357
+ const id = node.getAttribute('w:id');
358
+ if (id !== null && !ids.has(id)) ids.set(id, node);
359
+ }
360
+ for (let child = node.firstChild; child; child = child.nextSibling) stack.push(child);
361
+ }
362
+ return ids;
363
+ }
364
+
365
+ /**
366
+ * An untracked operation (generateRedlines: false) must not create any revision: every revision
367
+ * id (w:id on a revision element) in the output must already exist in the source, or be a fragment
368
+ * of a source revision sliced around the edit (same element, author and date).
369
+ *
370
+ * @param {Object} input
371
+ * @param {Document} input.before - savepoint DOM
372
+ * @param {Document} input.after - post-mutation DOM
373
+ */
374
+ export function verifyNoCreatedRevisions({ before, after, beforeViews = null, afterViews = null }) {
375
+ // Slicing a foreign revision around an untracked edit gives the fragments fresh ids but keeps the
376
+ // original element, author and date; that preserves the reviewer's revision rather than creating one.
377
+ const signature = node => `${nameOf(node)}|${node.getAttribute('w:author')}|${node.getAttribute('w:date')}`;
378
+ const signaturesOf = ids => new Map([...ids].map(([id, node]) => [id, signature(node)]));
379
+ // Cached as plain strings: the cached map's nodes may belong to a DOM that has since been mutated.
380
+ let existing = beforeViews?.revisionSignatures;
381
+ if (!existing) {
382
+ existing = signaturesOf(collectRevisionIds(before));
383
+ if (beforeViews) beforeViews.revisionSignatures = existing;
384
+ }
385
+ const afterIds = collectRevisionIds(after);
386
+ if (afterViews) afterViews.revisionSignatures = signaturesOf(afterIds);
387
+ const sliceable = new Set(existing.values());
388
+ const violations = [];
389
+ for (const [id, node] of afterIds) {
390
+ if (existing.has(id) || sliceable.has(signature(node))) continue;
391
+ const name = nameOf(node);
392
+ const author = node.getAttribute('w:author');
393
+ const text = excerpt(node.textContent || '');
394
+ violations.push({
395
+ invariant: 'UNTRACKED_CREATED_REVISION',
396
+ paragraph: null,
397
+ expected: 'no new revisions',
398
+ actual: `w:${name} w:id=${id}${author ? ` by ${author}` : ''}${text ? ` ("${text}")` : ''}`,
399
+ explanation: REVISION_FIDELITY_INVARIANTS.UNTRACKED_CREATED_REVISION.explanation,
400
+ source: null,
401
+ output: null
402
+ });
403
+ if (violations.length >= MAX_REPORTED_VIOLATIONS) break;
404
+ }
405
+ return violations.length ? { ok: false, violations } : { ok: true };
406
+ }
407
+
408
+ function neighborViolation(paragraph, sourceRecord, outputRecord, view) {
409
+ return {
410
+ invariant: 'ACCEPT_NEIGHBORS',
411
+ paragraph,
412
+ view,
413
+ expected: excerpt(sourceRecord?.text ?? null),
414
+ actual: excerpt(outputRecord?.text ?? null),
415
+ explanation: REVISION_FIDELITY_INVARIANTS.ACCEPT_NEIGHBORS.explanation,
416
+ source: describeRecord(sourceRecord),
417
+ output: describeRecord(outputRecord)
418
+ };
419
+ }
420
+
421
+ /**
422
+ * Accept-first policies accept only the target's revisions. With a known target scope
423
+ * (`{ from, to }`, zero-based body paragraph positions) those source paragraphs take the accepted
424
+ * view and all others the rejected view. Without one, the unchanged prefix and suffix keep the
425
+ * rejected view and the paragraphs between them take the accepted view (weaker: a lost revision
426
+ * adjacent to the target is absorbed).
427
+ */
428
+ function spliceAcceptedRegion(rejected, accepted, actual, scope) {
429
+ if (scope && Number.isInteger(scope.from) && Number.isInteger(scope.to)) {
430
+ const inScope = record => record.sourceIndex >= scope.from && record.sourceIndex <= scope.to;
431
+ return [...rejected.filter(record => !inScope(record)), ...accepted.filter(inScope)]
432
+ .sort((a, b) => a.sourceIndex - b.sourceIndex);
433
+ }
434
+ let prefix = 0;
435
+ while (prefix < rejected.length && prefix < actual.length && sameRecord(rejected[prefix], actual[prefix])) prefix++;
436
+ let suffix = 0;
437
+ while (suffix < rejected.length - prefix && suffix < actual.length - prefix
438
+ && sameRecord(rejected[rejected.length - 1 - suffix], actual[actual.length - 1 - suffix])) suffix++;
439
+ if (prefix + suffix === rejected.length && prefix + suffix === actual.length) return rejected;
440
+ const low = prefix > 0 ? rejected[prefix - 1].sourceIndex : -1;
441
+ const high = suffix > 0 ? rejected[rejected.length - suffix].sourceIndex : Infinity;
442
+ const middle = accepted.filter(record => record.sourceIndex > low && record.sourceIndex < high);
443
+ return [...rejected.slice(0, prefix), ...middle, ...rejected.slice(rejected.length - suffix)];
444
+ }
445
+
446
+ /**
447
+ * Builds the structured error payload for a violation result. The message alone is enough for a
448
+ * log line; `violations` and `recovery` carry the machine-readable detail for a retry.
449
+ */
450
+ export function buildRevisionFidelityError(violations, context = {}) {
451
+ const first = violations[0];
452
+ const where = first.source
453
+ ? `source paragraph ${first.source.sourceParagraphIndex}${first.source.text ? ` ("${first.source.text}")` : ''}`
454
+ : `paragraph ${first.paragraph} of the Reject All view`;
455
+ const invariants = [...new Set(violations.map(violation => violation.invariant))];
456
+ const mode = context.mode || DEFAULT_REVISION_FIDELITY_MODE;
457
+ return {
458
+ code: 'REVISION_FIDELITY_VIOLATION',
459
+ stage: 'verification',
460
+ message: `${mode === 'warn' ? 'Applied with a fidelity warning' : 'Refused, document unchanged'}: `
461
+ + (first.invariant === 'UNTRACKED_CREATED_REVISION'
462
+ ? `this untracked operation created a tracked revision (${first.invariant}: `
463
+ : first.invariant === 'ACCEPT_NEIGHBORS'
464
+ ? `this untracked operation changed a paragraph outside its target (${first.invariant} in the ${first.view} view at ${where}: `
465
+ : first.invariant === 'ACCEPT_INTENT'
466
+ ? `Accept All of this operation's target would not read as the requested text (${first.invariant} at ${where}: `
467
+ : `Reject All of this operation's output would not restore the source (${first.invariant} at ${where}: `)
468
+ + (first.invariant === 'UNTRACKED_CREATED_REVISION'
469
+ ? `found ${first.actual}). `
470
+ : `expected ${JSON.stringify(first.expected)}, got ${JSON.stringify(first.actual)}). `)
471
+ + `${first.explanation} `
472
+ + `Retry: ${REVISION_FIDELITY_INVARIANTS[first.invariant].retry}`,
473
+ violations,
474
+ invariants,
475
+ verificationMode: mode,
476
+ ...(context.baselineView === 'accepted' ? { baselineView: 'accepted' } : {}),
477
+ recovery: {
478
+ hint: REVISION_FIDELITY_INVARIANTS[first.invariant].retry,
479
+ retryGuidance: invariants.map(invariant => ({ invariant, retry: REVISION_FIDELITY_INVARIANTS[invariant].retry })),
480
+ reportIfNarrowRetryFails: 'If a narrower operation on the same paragraph is also refused, the shape is unsupported: report the violations with the operation; do not disable verification to force it through.'
481
+ }
482
+ };
483
+ }
484
+
485
+ /** Baseline view for an existing-revisions policy: accept-first policies edit the accepted source. */
486
+ export function revisionFidelityBaselineView(existingRevisions) {
487
+ return String(existingRevisions || '').startsWith('accept-all-first') ? 'accepted' : 'rejected';
488
+ }
489
+
490
+ /**
491
+ * Resolves the effective mode. An explicit option wins; otherwise the diagnostic
492
+ * DOCX_REDLINE_REVISION_FIDELITY environment variable (test lanes); otherwise 'enforce'.
493
+ */
494
+ export function resolveRevisionFidelityMode(value) {
495
+ if (REVISION_FIDELITY_MODES.includes(value)) return value;
496
+ const fromEnvironment = globalThis.process?.env?.DOCX_REDLINE_REVISION_FIDELITY;
497
+ return REVISION_FIDELITY_MODES.includes(fromEnvironment) ? fromEnvironment : DEFAULT_REVISION_FIDELITY_MODE;
498
+ }