docrev 0.10.1 → 0.11.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 (167) hide show
  1. package/.gitattributes +1 -1
  2. package/CHANGELOG.md +184 -173
  3. package/PLAN-tables-and-postprocess.md +850 -850
  4. package/README.md +431 -431
  5. package/bin/rev.js +11 -11
  6. package/bin/rev.ts +145 -145
  7. package/completions/rev.bash +127 -127
  8. package/completions/rev.ps1 +210 -210
  9. package/completions/rev.zsh +207 -207
  10. package/dist/lib/anchor-match.d.ts +4 -2
  11. package/dist/lib/anchor-match.d.ts.map +1 -1
  12. package/dist/lib/anchor-match.js +52 -24
  13. package/dist/lib/anchor-match.js.map +1 -1
  14. package/dist/lib/annotations.d.ts +0 -6
  15. package/dist/lib/annotations.d.ts.map +1 -1
  16. package/dist/lib/annotations.js +26 -8
  17. package/dist/lib/annotations.js.map +1 -1
  18. package/dist/lib/build.d.ts.map +1 -1
  19. package/dist/lib/build.js +13 -10
  20. package/dist/lib/build.js.map +1 -1
  21. package/dist/lib/commands/build.d.ts.map +1 -1
  22. package/dist/lib/commands/build.js +5 -0
  23. package/dist/lib/commands/build.js.map +1 -1
  24. package/dist/lib/commands/doi.d.ts.map +1 -1
  25. package/dist/lib/commands/doi.js +14 -2
  26. package/dist/lib/commands/doi.js.map +1 -1
  27. package/dist/lib/commands/sync.d.ts.map +1 -1
  28. package/dist/lib/commands/sync.js +98 -107
  29. package/dist/lib/commands/sync.js.map +1 -1
  30. package/dist/lib/commands/utilities.js +164 -164
  31. package/dist/lib/commands/word-tools.js +8 -8
  32. package/dist/lib/comment-realign.d.ts +12 -21
  33. package/dist/lib/comment-realign.d.ts.map +1 -1
  34. package/dist/lib/comment-realign.js +40 -348
  35. package/dist/lib/comment-realign.js.map +1 -1
  36. package/dist/lib/dependencies.d.ts +10 -0
  37. package/dist/lib/dependencies.d.ts.map +1 -1
  38. package/dist/lib/dependencies.js +41 -9
  39. package/dist/lib/dependencies.js.map +1 -1
  40. package/dist/lib/doi.d.ts.map +1 -1
  41. package/dist/lib/doi.js +68 -80
  42. package/dist/lib/doi.js.map +1 -1
  43. package/dist/lib/errors.d.ts.map +1 -1
  44. package/dist/lib/errors.js +3 -1
  45. package/dist/lib/errors.js.map +1 -1
  46. package/dist/lib/grammar.js +3 -3
  47. package/dist/lib/import.d.ts +7 -4
  48. package/dist/lib/import.d.ts.map +1 -1
  49. package/dist/lib/import.js +58 -62
  50. package/dist/lib/import.js.map +1 -1
  51. package/dist/lib/macro-filter.lua +201 -201
  52. package/dist/lib/ooxml.d.ts +207 -0
  53. package/dist/lib/ooxml.d.ts.map +1 -0
  54. package/dist/lib/ooxml.js +634 -0
  55. package/dist/lib/ooxml.js.map +1 -0
  56. package/dist/lib/pdf-comments.js +44 -44
  57. package/dist/lib/plugins.js +57 -57
  58. package/dist/lib/pptx-color-filter.lua +37 -37
  59. package/dist/lib/pptx-themes.js +115 -115
  60. package/dist/lib/rate-limiter.d.ts +8 -0
  61. package/dist/lib/rate-limiter.d.ts.map +1 -1
  62. package/dist/lib/rate-limiter.js +41 -4
  63. package/dist/lib/rate-limiter.js.map +1 -1
  64. package/dist/lib/sections.d.ts.map +1 -1
  65. package/dist/lib/sections.js +5 -2
  66. package/dist/lib/sections.js.map +1 -1
  67. package/dist/lib/spelling.js +2 -2
  68. package/dist/lib/templates.js +387 -387
  69. package/dist/lib/themes.js +51 -51
  70. package/dist/lib/types.d.ts +9 -1
  71. package/dist/lib/types.d.ts.map +1 -1
  72. package/dist/lib/word-extraction.d.ts.map +1 -1
  73. package/dist/lib/word-extraction.js +48 -307
  74. package/dist/lib/word-extraction.js.map +1 -1
  75. package/dist/lib/word.d.ts.map +1 -1
  76. package/dist/lib/word.js +80 -175
  77. package/dist/lib/word.js.map +1 -1
  78. package/dist/lib/wordcomments.d.ts.map +1 -1
  79. package/dist/lib/wordcomments.js +53 -92
  80. package/dist/lib/wordcomments.js.map +1 -1
  81. package/docs-src/build.py +113 -113
  82. package/docs-src/extra.css +208 -208
  83. package/docs-src/md-to-html.lua +6 -6
  84. package/docs-src/template.html +116 -116
  85. package/eslint.config.js +27 -27
  86. package/lib/anchor-match.ts +307 -276
  87. package/lib/annotations.ts +664 -644
  88. package/lib/build.ts +1770 -1766
  89. package/lib/citations.ts +160 -160
  90. package/lib/commands/build.ts +860 -855
  91. package/lib/commands/citations.ts +515 -515
  92. package/lib/commands/comments.ts +1050 -1050
  93. package/lib/commands/context.ts +176 -176
  94. package/lib/commands/core.ts +309 -309
  95. package/lib/commands/doi.ts +451 -435
  96. package/lib/commands/file-ops.ts +372 -372
  97. package/lib/commands/history.ts +320 -320
  98. package/lib/commands/index.ts +87 -87
  99. package/lib/commands/init.ts +259 -259
  100. package/lib/commands/merge-resolve.ts +378 -378
  101. package/lib/commands/preview.ts +178 -178
  102. package/lib/commands/project-info.ts +244 -244
  103. package/lib/commands/quality.ts +517 -517
  104. package/lib/commands/response.ts +454 -454
  105. package/lib/commands/section-boundaries.ts +82 -82
  106. package/lib/commands/sections.ts +451 -451
  107. package/lib/commands/sync.ts +689 -709
  108. package/lib/commands/text-ops.ts +449 -449
  109. package/lib/commands/utilities.ts +448 -448
  110. package/lib/commands/verify-anchors.ts +272 -272
  111. package/lib/commands/word-tools.ts +340 -340
  112. package/lib/comment-realign.ts +99 -517
  113. package/lib/config.ts +84 -84
  114. package/lib/crossref.ts +781 -781
  115. package/lib/csl.ts +191 -191
  116. package/lib/dependencies.ts +132 -98
  117. package/lib/diff-engine.ts +465 -465
  118. package/lib/doi-cache.ts +115 -115
  119. package/lib/doi.ts +879 -897
  120. package/lib/equations.ts +506 -506
  121. package/lib/errors.ts +350 -346
  122. package/lib/format.ts +541 -541
  123. package/lib/git.ts +326 -326
  124. package/lib/grammar.ts +303 -303
  125. package/lib/image-registry.ts +180 -180
  126. package/lib/import.ts +906 -911
  127. package/lib/journals.ts +543 -543
  128. package/lib/macro-filter.lua +201 -201
  129. package/lib/macros.ts +273 -273
  130. package/lib/merge.ts +633 -633
  131. package/lib/ooxml.ts +768 -0
  132. package/lib/orcid.ts +144 -144
  133. package/lib/pdf-comments.ts +263 -263
  134. package/lib/pdf-import.ts +524 -524
  135. package/lib/plugins.ts +362 -362
  136. package/lib/postprocess.ts +188 -188
  137. package/lib/pptx-color-filter.lua +37 -37
  138. package/lib/pptx-template.ts +469 -469
  139. package/lib/pptx-themes.ts +483 -483
  140. package/lib/protect-restore.ts +520 -520
  141. package/lib/rate-limiter.ts +127 -94
  142. package/lib/response.ts +197 -197
  143. package/lib/restore-references.ts +240 -240
  144. package/lib/review.ts +327 -327
  145. package/lib/schema.ts +488 -488
  146. package/lib/scientific-words.ts +73 -73
  147. package/lib/sections.ts +428 -425
  148. package/lib/slides.ts +756 -756
  149. package/lib/spelling.ts +334 -334
  150. package/lib/templates.ts +526 -526
  151. package/lib/themes.ts +742 -742
  152. package/lib/trackchanges.ts +247 -247
  153. package/lib/tui.ts +450 -450
  154. package/lib/types.ts +558 -550
  155. package/lib/undo.ts +250 -250
  156. package/lib/utils.ts +69 -69
  157. package/lib/variables.ts +179 -179
  158. package/lib/word-extraction.ts +525 -806
  159. package/lib/word.ts +526 -643
  160. package/lib/wordcomments.ts +789 -840
  161. package/mkdocs.yml +64 -64
  162. package/package.json +137 -137
  163. package/scripts/postbuild.js +47 -47
  164. package/skill/REFERENCE.md +539 -539
  165. package/skill/SKILL.md +295 -295
  166. package/tsconfig.json +26 -26
  167. package/types/index.d.ts +525 -525
package/lib/merge.ts CHANGED
@@ -1,633 +1,633 @@
1
- /**
2
- * Multi-reviewer merge utilities
3
- * Combine feedback from multiple Word documents with conflict detection
4
- *
5
- * Supports true three-way merge: base document + multiple reviewer versions
6
- */
7
-
8
- import * as fs from 'fs';
9
- import * as path from 'path';
10
- import * as crypto from 'crypto';
11
- import { diffWords, diffSentences } from 'diff';
12
- import { extractFromWord, extractWordComments } from './import.js';
13
- import type { ReviewerChange, Conflict, MergeResult } from './types.js';
14
-
15
- // =============================================================================
16
- // Constants
17
- // =============================================================================
18
-
19
- /** Directory for revision tracking data */
20
- const REV_DIR = '.rev';
21
-
22
- /** Path to base document for three-way merge */
23
- const BASE_FILE = '.rev/base.docx';
24
-
25
- /** Path to conflict resolution state */
26
- const CONFLICTS_FILE = '.rev/conflicts.json';
27
-
28
- /** Minimum word length for similarity calculations */
29
- const MIN_WORD_LENGTH = 2;
30
-
31
- /** Similarity threshold below which changes are considered conflicts */
32
- const CONFLICT_SIMILARITY_THRESHOLD = 0.8;
33
-
34
- /** Characters of context for change attribution */
35
- const CHANGE_CONTEXT_SIZE = 50;
36
-
37
- // =============================================================================
38
- // Interfaces
39
- // =============================================================================
40
-
41
- interface ReviewerDoc {
42
- path: string;
43
- name: string;
44
- }
45
-
46
- interface ReviewerComment {
47
- text: string;
48
- reviewer: string;
49
- }
50
-
51
- interface MergeOptions {
52
- diffLevel?: 'sentence' | 'word';
53
- autoResolve?: boolean;
54
- }
55
-
56
- interface CheckMatchResult {
57
- matches: boolean;
58
- similarity: number;
59
- }
60
-
61
- interface ConflictDetectionResult {
62
- conflicts: Conflict[];
63
- nonConflicting: ReviewerChange[];
64
- }
65
-
66
- interface ConflictsData {
67
- base: string;
68
- merged: string;
69
- conflicts: Conflict[];
70
- }
71
-
72
- // =============================================================================
73
- // Public API
74
- // =============================================================================
75
-
76
- /**
77
- * Initialize .rev directory for revision tracking
78
- * @param projectDir - Project directory path
79
- * @throws {TypeError} If projectDir is not a string
80
- */
81
- export function initRevDir(projectDir: string): void {
82
- if (typeof projectDir !== 'string') {
83
- throw new TypeError(`projectDir must be a string, got ${typeof projectDir}`);
84
- }
85
-
86
- const revDir = path.join(projectDir, REV_DIR);
87
- if (!fs.existsSync(revDir)) {
88
- fs.mkdirSync(revDir, { recursive: true });
89
- }
90
- }
91
-
92
- /**
93
- * Store the base document for three-way merge
94
- * Overwrites any previous base document
95
- * @param projectDir - Project directory path
96
- * @param docxPath - Path to the built docx to store as base
97
- * @throws {TypeError} If arguments are not strings
98
- * @throws {Error} If docxPath does not exist
99
- */
100
- export function storeBaseDocument(projectDir: string, docxPath: string): void {
101
- if (typeof projectDir !== 'string') {
102
- throw new TypeError(`projectDir must be a string, got ${typeof projectDir}`);
103
- }
104
- if (typeof docxPath !== 'string') {
105
- throw new TypeError(`docxPath must be a string, got ${typeof docxPath}`);
106
- }
107
- if (!fs.existsSync(docxPath)) {
108
- throw new Error(`Source document not found: ${docxPath}`);
109
- }
110
-
111
- initRevDir(projectDir);
112
- const basePath = path.join(projectDir, BASE_FILE);
113
- fs.copyFileSync(docxPath, basePath);
114
- }
115
-
116
- /**
117
- * Get the base document path if it exists
118
- * @param projectDir - Project directory path
119
- * @returns Path to base document or null if not found
120
- * @throws {TypeError} If projectDir is not a string
121
- */
122
- export function getBaseDocument(projectDir: string): string | null {
123
- if (typeof projectDir !== 'string') {
124
- throw new TypeError(`projectDir must be a string, got ${typeof projectDir}`);
125
- }
126
-
127
- const basePath = path.join(projectDir, BASE_FILE);
128
- if (fs.existsSync(basePath)) {
129
- return basePath;
130
- }
131
- return null;
132
- }
133
-
134
- /**
135
- * Check if base document exists
136
- * @param projectDir - Project directory path
137
- * @returns True if base document exists
138
- * @throws {TypeError} If projectDir is not a string
139
- */
140
- export function hasBaseDocument(projectDir: string): boolean {
141
- if (typeof projectDir !== 'string') {
142
- throw new TypeError(`projectDir must be a string, got ${typeof projectDir}`);
143
- }
144
-
145
- return fs.existsSync(path.join(projectDir, BASE_FILE));
146
- }
147
-
148
- /**
149
- * Compute text similarity between two strings using Jaccard-like coefficient
150
- * @param text1 - First text to compare
151
- * @param text2 - Second text to compare
152
- * @returns Similarity score 0-1 (0 = no similarity, 1 = identical)
153
- */
154
- export function computeSimilarity(text1: string, text2: string): number {
155
- if (typeof text1 !== 'string' || typeof text2 !== 'string') {
156
- return 0;
157
- }
158
-
159
- const words1 = new Set(text1.toLowerCase().split(/\s+/).filter(w => w.length > MIN_WORD_LENGTH));
160
- const words2 = text2.toLowerCase().split(/\s+/).filter(w => w.length > MIN_WORD_LENGTH);
161
- if (words1.size === 0 || words2.length === 0) return 0;
162
- const common = words2.filter(w => words1.has(w)).length;
163
- return common / Math.max(words1.size, words2.length);
164
- }
165
-
166
- /**
167
- * Check if base document matches reviewer document (similarity check)
168
- */
169
- export async function checkBaseMatch(basePath: string, reviewerPath: string): Promise<CheckMatchResult> {
170
- try {
171
- const { text: baseText } = await extractFromWord(basePath);
172
- const { text: reviewerText } = await extractFromWord(reviewerPath);
173
- const similarity = computeSimilarity(baseText, reviewerText);
174
- return { matches: similarity > 0.5, similarity };
175
- } catch {
176
- return { matches: false, similarity: 0 };
177
- }
178
- }
179
-
180
- /**
181
- * Extract changes from diffs between original and modified text
182
- * @param diffs - Array of diff changes
183
- * @param reviewer - Reviewer identifier
184
- */
185
- function extractChangesFromDiffs(
186
- diffs: Array<{ added?: boolean; removed?: boolean; value: string }>,
187
- reviewer: string,
188
- ): ReviewerChange[] {
189
- const changes: ReviewerChange[] = [];
190
- let originalPos = 0;
191
- let i = 0;
192
-
193
- while (i < diffs.length) {
194
- const part = diffs[i];
195
- if (!part) break;
196
-
197
- if (!part.added && !part.removed) {
198
- originalPos += part.value.length;
199
- i++;
200
- } else if (part.removed && diffs[i + 1]?.added) {
201
- const nextPart = diffs[i + 1];
202
- if (!nextPart) break;
203
- changes.push({
204
- reviewer,
205
- type: 'replace',
206
- start: originalPos,
207
- end: originalPos + part.value.length,
208
- oldText: part.value,
209
- newText: nextPart.value,
210
- });
211
- originalPos += part.value.length;
212
- i += 2;
213
- } else if (part.removed) {
214
- changes.push({
215
- reviewer,
216
- type: 'delete',
217
- start: originalPos,
218
- end: originalPos + part.value.length,
219
- oldText: part.value,
220
- newText: '',
221
- });
222
- originalPos += part.value.length;
223
- i++;
224
- } else if (part.added) {
225
- changes.push({
226
- reviewer,
227
- type: 'insert',
228
- start: originalPos,
229
- end: originalPos,
230
- oldText: '',
231
- newText: part.value,
232
- });
233
- i++;
234
- }
235
- }
236
-
237
- return changes;
238
- }
239
-
240
- /**
241
- * Extract changes from a Word document compared to original
242
- * Uses sentence-level diffing for better conflict detection
243
- * @param originalText - Original text (from base document)
244
- * @param wordText - Text extracted from reviewer's Word doc
245
- * @param reviewer - Reviewer identifier
246
- */
247
- export function extractChanges(originalText: string, wordText: string, reviewer: string): ReviewerChange[] {
248
- return extractChangesFromDiffs(diffSentences(originalText, wordText), reviewer);
249
- }
250
-
251
- /**
252
- * Extract changes using word-level diff (more fine-grained)
253
- */
254
- export function extractChangesWordLevel(originalText: string, wordText: string, reviewer: string): ReviewerChange[] {
255
- return extractChangesFromDiffs(diffWords(originalText, wordText), reviewer);
256
- }
257
-
258
- /**
259
- * Check if two changes overlap
260
- */
261
- function changesOverlap(a: ReviewerChange, b: ReviewerChange): boolean {
262
- // Insertions at same point conflict
263
- if (a.type === 'insert' && b.type === 'insert' && a.start === b.start) {
264
- return a.newText !== b.newText; // Same insertion is not a conflict
265
- }
266
-
267
- // Check range overlap
268
- const aStart = a.start;
269
- const aEnd = a.type === 'insert' ? a.start : a.end;
270
- const bStart = b.start;
271
- const bEnd = b.type === 'insert' ? b.start : b.end;
272
-
273
- // Ranges overlap if neither ends before the other starts
274
- if (aEnd <= bStart || bEnd <= aStart) {
275
- return false;
276
- }
277
-
278
- // They overlap - but is it a conflict?
279
- // Same change from different reviewers is not a conflict
280
- if (a.type === b.type && a.oldText === b.oldText && a.newText === b.newText) {
281
- return false;
282
- }
283
-
284
- return true;
285
- }
286
-
287
- /**
288
- * Detect conflicts between changes from multiple reviewers
289
- * @param allChanges - Array of change arrays, one per reviewer
290
- */
291
- export function detectConflicts(allChanges: ReviewerChange[][]): ConflictDetectionResult {
292
- // Flatten and sort all changes by position
293
- const flat = allChanges.flat().sort((a, b) => a.start - b.start || a.end - b.end);
294
-
295
- const conflicts: Conflict[] = [];
296
- const nonConflicting: ReviewerChange[] = [];
297
- const usedIndices = new Set<number>();
298
- let conflictId = 0;
299
-
300
- for (let i = 0; i < flat.length; i++) {
301
- if (usedIndices.has(i)) continue;
302
-
303
- const change = flat[i];
304
- if (!change) continue;
305
- const conflictingChanges = [change];
306
-
307
- // Find all changes that conflict with this one
308
- for (let j = i + 1; j < flat.length; j++) {
309
- if (usedIndices.has(j)) continue;
310
-
311
- const other = flat[j];
312
- if (!other) continue;
313
-
314
- // Stop if we're past the range
315
- if (other.start > change.end && change.type !== 'insert') break;
316
-
317
- if (changesOverlap(change, other)) {
318
- conflictingChanges.push(other);
319
- usedIndices.add(j);
320
- }
321
- }
322
-
323
- if (conflictingChanges.length > 1) {
324
- // Multiple reviewers changed the same region
325
- const start = Math.min(...conflictingChanges.map(c => c?.start ?? 0).filter(s => s !== undefined));
326
- const end = Math.max(...conflictingChanges.map(c => c?.end ?? 0).filter(e => e !== undefined));
327
- const firstChange = conflictingChanges[0];
328
-
329
- conflicts.push({
330
- id: `c${++conflictId}`,
331
- start,
332
- end,
333
- original: firstChange?.oldText || '',
334
- changes: conflictingChanges.filter((c): c is ReviewerChange => c !== undefined),
335
- resolved: null,
336
- });
337
- usedIndices.add(i);
338
- } else {
339
- // No conflict
340
- nonConflicting.push(change);
341
- usedIndices.add(i);
342
- }
343
- }
344
-
345
- // Deduplicate identical non-conflicting changes
346
- const seen = new Map<string, boolean>();
347
- const dedupedNonConflicting: ReviewerChange[] = [];
348
-
349
- for (const change of nonConflicting) {
350
- const key = `${change.start}:${change.end}:${change.type}:${change.newText}`;
351
- if (!seen.has(key)) {
352
- seen.set(key, true);
353
- dedupedNonConflicting.push(change);
354
- }
355
- }
356
-
357
- return { conflicts, nonConflicting: dedupedNonConflicting };
358
- }
359
-
360
- /**
361
- * Apply non-conflicting changes to text
362
- * @param originalText
363
- * @param changes - Must be sorted by position
364
- */
365
- export function applyChanges(originalText: string, changes: ReviewerChange[]): string {
366
- // Sort by position descending to apply from end to start
367
- const sorted = [...changes].sort((a, b) => b.start - a.start);
368
-
369
- let result = originalText;
370
-
371
- for (const change of sorted) {
372
- if (change.type === 'insert') {
373
- result = result.slice(0, change.start) + change.newText + result.slice(change.start);
374
- } else if (change.type === 'delete') {
375
- result = result.slice(0, change.start) + result.slice(change.end);
376
- } else if (change.type === 'replace') {
377
- result = result.slice(0, change.start) + change.newText + result.slice(change.end);
378
- }
379
- }
380
-
381
- return result;
382
- }
383
-
384
- /**
385
- * Apply changes as CriticMarkup annotations
386
- */
387
- export function applyChangesAsAnnotations(originalText: string, changes: ReviewerChange[]): string {
388
- const sorted = [...changes].sort((a, b) => b.start - a.start);
389
-
390
- let result = originalText;
391
-
392
- for (const change of sorted) {
393
- if (change.type === 'insert') {
394
- const annotation = `{++${change.newText}++}`;
395
- result = result.slice(0, change.start) + annotation + result.slice(change.start);
396
- } else if (change.type === 'delete') {
397
- const annotation = `{--${change.oldText}--}`;
398
- result = result.slice(0, change.start) + annotation + result.slice(change.end);
399
- } else if (change.type === 'replace') {
400
- const annotation = `{~~${change.oldText}~>${change.newText}~~}`;
401
- result = result.slice(0, change.start) + annotation + result.slice(change.end);
402
- }
403
- }
404
-
405
- return result;
406
- }
407
-
408
- /**
409
- * Apply changes as git-style conflict markers
410
- */
411
- export function applyConflictMarkers(originalText: string, conflicts: Conflict[]): string {
412
- // Sort by position descending
413
- const sorted = [...conflicts].sort((a, b) => b.start - a.start);
414
-
415
- let result = originalText;
416
-
417
- for (const conflict of sorted) {
418
- const markers: string[] = [];
419
- markers.push(`<<<<<<< CONFLICT ${conflict.id}`);
420
-
421
- for (const change of conflict.changes) {
422
- markers.push(`======= ${change.reviewer}`);
423
- if (change.type === 'delete') {
424
- markers.push(`[DELETED: "${change.oldText}"]`);
425
- } else if (change.type === 'insert') {
426
- markers.push(change.newText);
427
- } else {
428
- markers.push(change.newText);
429
- }
430
- }
431
-
432
- markers.push(`>>>>>>> END ${conflict.id}`);
433
-
434
- const markerText = markers.join('\n');
435
- result = result.slice(0, conflict.start) + markerText + result.slice(conflict.end);
436
- }
437
-
438
- return result;
439
- }
440
-
441
- /**
442
- * Format a conflict for display
443
- */
444
- export function formatConflict(conflict: Conflict, originalText: string): string {
445
- const lines: string[] = [];
446
- const context = 50;
447
-
448
- // Show context
449
- const beforeStart = Math.max(0, conflict.start - context);
450
- const afterEnd = Math.min(originalText.length, conflict.end + context);
451
-
452
- const before = originalText.slice(beforeStart, conflict.start).trim();
453
- const original = originalText.slice(conflict.start, conflict.end);
454
- const after = originalText.slice(conflict.end, afterEnd).trim();
455
-
456
- if (before) {
457
- lines.push(` ...${before}`);
458
- }
459
- lines.push(` [ORIGINAL]: "${original || '(insertion point)'}"`);
460
- if (after) {
461
- lines.push(` ${after}...`);
462
- }
463
- lines.push('');
464
- lines.push(' Options:');
465
-
466
- conflict.changes.forEach((change, i) => {
467
- const label = change.type === 'insert'
468
- ? `Insert: "${change.newText.slice(0, 60)}${change.newText.length > 60 ? '...' : ''}"`
469
- : change.type === 'delete'
470
- ? `Delete: "${change.oldText.slice(0, 60)}${change.oldText.length > 60 ? '...' : ''}"`
471
- : `Replace → "${change.newText.slice(0, 60)}${change.newText.length > 60 ? '...' : ''}"`;
472
- lines.push(` ${i + 1}. [${change.reviewer}] ${label}`);
473
- });
474
-
475
- return lines.join('\n');
476
- }
477
-
478
- /**
479
- * Save conflicts to file for later resolution
480
- */
481
- export function saveConflicts(projectDir: string, conflicts: Conflict[], baseDoc: string): void {
482
- const conflictsPath = path.join(projectDir, CONFLICTS_FILE);
483
- const data: ConflictsData = {
484
- base: baseDoc,
485
- merged: new Date().toISOString(),
486
- conflicts,
487
- };
488
-
489
- // Ensure directory exists
490
- const dir = path.dirname(conflictsPath);
491
- if (!fs.existsSync(dir)) {
492
- fs.mkdirSync(dir, { recursive: true });
493
- }
494
-
495
- fs.writeFileSync(conflictsPath, JSON.stringify(data, null, 2));
496
- }
497
-
498
- /**
499
- * Load conflicts from file
500
- */
501
- export function loadConflicts(projectDir: string): ConflictsData | null {
502
- const conflictsPath = path.join(projectDir, CONFLICTS_FILE);
503
- if (!fs.existsSync(conflictsPath)) {
504
- return null;
505
- }
506
- return JSON.parse(fs.readFileSync(conflictsPath, 'utf-8')) as ConflictsData;
507
- }
508
-
509
- /**
510
- * Clear conflicts file after resolution
511
- */
512
- export function clearConflicts(projectDir: string): void {
513
- const conflictsPath = path.join(projectDir, CONFLICTS_FILE);
514
- if (fs.existsSync(conflictsPath)) {
515
- fs.unlinkSync(conflictsPath);
516
- }
517
- }
518
-
519
- /**
520
- * Core merge logic: extract changes from reviewer docs, detect conflicts, apply annotations
521
- */
522
- async function mergeReviewerDocsCore(
523
- baseText: string,
524
- reviewerDocs: ReviewerDoc[],
525
- options: MergeOptions = {},
526
- ): Promise<MergeResult> {
527
- const { diffLevel = 'sentence' } = options;
528
-
529
- const allChanges: ReviewerChange[][] = [];
530
- const allComments: ReviewerComment[] = [];
531
-
532
- for (const doc of reviewerDocs) {
533
- if (!fs.existsSync(doc.path)) {
534
- throw new Error(`Reviewer file not found: ${doc.path}`);
535
- }
536
-
537
- const { text: wordText } = await extractFromWord(doc.path);
538
-
539
- const changes = diffLevel === 'word'
540
- ? extractChangesWordLevel(baseText, wordText, doc.name)
541
- : extractChanges(baseText, wordText, doc.name);
542
-
543
- allChanges.push(changes);
544
-
545
- try {
546
- const comments = await extractWordComments(doc.path);
547
- allComments.push(...comments.map(c => ({ ...c, reviewer: doc.name })));
548
- } catch (e) {
549
- if (process.env.DEBUG) {
550
- const error = e as Error;
551
- console.warn(`merge: Failed to extract comments:`, error.message);
552
- }
553
- }
554
- }
555
-
556
- const { conflicts, nonConflicting } = detectConflicts(allChanges);
557
-
558
- let merged = applyChangesAsAnnotations(baseText, nonConflicting);
559
-
560
- for (const comment of allComments) {
561
- merged += `\n{>>${comment.reviewer}: ${comment.text}<<}`;
562
- }
563
-
564
- const stats = {
565
- reviewers: reviewerDocs.length,
566
- totalChanges: allChanges.flat().length,
567
- nonConflicting: nonConflicting.length,
568
- conflicts: conflicts.length,
569
- comments: allComments.length,
570
- };
571
-
572
- return { merged, conflicts, stats, originalText: baseText };
573
- }
574
-
575
- /**
576
- * Merge multiple Word documents using three-way merge
577
- */
578
- export async function mergeThreeWay(
579
- basePath: string,
580
- reviewerDocs: ReviewerDoc[],
581
- options: MergeOptions = {}
582
- ): Promise<MergeResult & { baseText: string }> {
583
- if (!fs.existsSync(basePath)) {
584
- throw new Error(`Base document not found: ${basePath}`);
585
- }
586
-
587
- const { text: baseText } = await extractFromWord(basePath);
588
- const result = await mergeReviewerDocsCore(baseText, reviewerDocs, options);
589
- return { ...result, baseText };
590
- }
591
-
592
- /**
593
- * Merge multiple Word documents against an original markdown file
594
- * Legacy function - use mergeThreeWay for proper three-way merge
595
- */
596
- export async function mergeReviewerDocs(
597
- originalPath: string,
598
- reviewerDocs: ReviewerDoc[],
599
- options: MergeOptions = {}
600
- ): Promise<MergeResult> {
601
- if (!fs.existsSync(originalPath)) {
602
- throw new Error(`Original file not found: ${originalPath}`);
603
- }
604
-
605
- const originalText = fs.readFileSync(originalPath, 'utf-8');
606
- return mergeReviewerDocsCore(originalText, reviewerDocs, options);
607
- }
608
-
609
- /**
610
- * Resolve a conflict by choosing one option
611
- * @param conflict
612
- * @param choice - Index of chosen change (0-based)
613
- */
614
- export function resolveConflict(conflict: Conflict, choice: number): ReviewerChange {
615
- if (choice < 0 || choice >= conflict.changes.length) {
616
- throw new Error(`Invalid choice: ${choice}. Must be 0-${conflict.changes.length - 1}`);
617
- }
618
- const selectedChange = conflict.changes[choice];
619
- if (!selectedChange) {
620
- throw new Error(`Invalid choice: ${choice}. Change not found`);
621
- }
622
- conflict.resolved = selectedChange.reviewer;
623
- return selectedChange;
624
- }
625
-
626
- /**
627
- * Get list of unresolved conflicts
628
- */
629
- export function getUnresolvedConflicts(projectDir: string): Conflict[] {
630
- const data = loadConflicts(projectDir);
631
- if (!data) return [];
632
- return data.conflicts.filter(c => c.resolved === null);
633
- }
1
+ /**
2
+ * Multi-reviewer merge utilities
3
+ * Combine feedback from multiple Word documents with conflict detection
4
+ *
5
+ * Supports true three-way merge: base document + multiple reviewer versions
6
+ */
7
+
8
+ import * as fs from 'fs';
9
+ import * as path from 'path';
10
+ import * as crypto from 'crypto';
11
+ import { diffWords, diffSentences } from 'diff';
12
+ import { extractFromWord, extractWordComments } from './import.js';
13
+ import type { ReviewerChange, Conflict, MergeResult } from './types.js';
14
+
15
+ // =============================================================================
16
+ // Constants
17
+ // =============================================================================
18
+
19
+ /** Directory for revision tracking data */
20
+ const REV_DIR = '.rev';
21
+
22
+ /** Path to base document for three-way merge */
23
+ const BASE_FILE = '.rev/base.docx';
24
+
25
+ /** Path to conflict resolution state */
26
+ const CONFLICTS_FILE = '.rev/conflicts.json';
27
+
28
+ /** Minimum word length for similarity calculations */
29
+ const MIN_WORD_LENGTH = 2;
30
+
31
+ /** Similarity threshold below which changes are considered conflicts */
32
+ const CONFLICT_SIMILARITY_THRESHOLD = 0.8;
33
+
34
+ /** Characters of context for change attribution */
35
+ const CHANGE_CONTEXT_SIZE = 50;
36
+
37
+ // =============================================================================
38
+ // Interfaces
39
+ // =============================================================================
40
+
41
+ interface ReviewerDoc {
42
+ path: string;
43
+ name: string;
44
+ }
45
+
46
+ interface ReviewerComment {
47
+ text: string;
48
+ reviewer: string;
49
+ }
50
+
51
+ interface MergeOptions {
52
+ diffLevel?: 'sentence' | 'word';
53
+ autoResolve?: boolean;
54
+ }
55
+
56
+ interface CheckMatchResult {
57
+ matches: boolean;
58
+ similarity: number;
59
+ }
60
+
61
+ interface ConflictDetectionResult {
62
+ conflicts: Conflict[];
63
+ nonConflicting: ReviewerChange[];
64
+ }
65
+
66
+ interface ConflictsData {
67
+ base: string;
68
+ merged: string;
69
+ conflicts: Conflict[];
70
+ }
71
+
72
+ // =============================================================================
73
+ // Public API
74
+ // =============================================================================
75
+
76
+ /**
77
+ * Initialize .rev directory for revision tracking
78
+ * @param projectDir - Project directory path
79
+ * @throws {TypeError} If projectDir is not a string
80
+ */
81
+ export function initRevDir(projectDir: string): void {
82
+ if (typeof projectDir !== 'string') {
83
+ throw new TypeError(`projectDir must be a string, got ${typeof projectDir}`);
84
+ }
85
+
86
+ const revDir = path.join(projectDir, REV_DIR);
87
+ if (!fs.existsSync(revDir)) {
88
+ fs.mkdirSync(revDir, { recursive: true });
89
+ }
90
+ }
91
+
92
+ /**
93
+ * Store the base document for three-way merge
94
+ * Overwrites any previous base document
95
+ * @param projectDir - Project directory path
96
+ * @param docxPath - Path to the built docx to store as base
97
+ * @throws {TypeError} If arguments are not strings
98
+ * @throws {Error} If docxPath does not exist
99
+ */
100
+ export function storeBaseDocument(projectDir: string, docxPath: string): void {
101
+ if (typeof projectDir !== 'string') {
102
+ throw new TypeError(`projectDir must be a string, got ${typeof projectDir}`);
103
+ }
104
+ if (typeof docxPath !== 'string') {
105
+ throw new TypeError(`docxPath must be a string, got ${typeof docxPath}`);
106
+ }
107
+ if (!fs.existsSync(docxPath)) {
108
+ throw new Error(`Source document not found: ${docxPath}`);
109
+ }
110
+
111
+ initRevDir(projectDir);
112
+ const basePath = path.join(projectDir, BASE_FILE);
113
+ fs.copyFileSync(docxPath, basePath);
114
+ }
115
+
116
+ /**
117
+ * Get the base document path if it exists
118
+ * @param projectDir - Project directory path
119
+ * @returns Path to base document or null if not found
120
+ * @throws {TypeError} If projectDir is not a string
121
+ */
122
+ export function getBaseDocument(projectDir: string): string | null {
123
+ if (typeof projectDir !== 'string') {
124
+ throw new TypeError(`projectDir must be a string, got ${typeof projectDir}`);
125
+ }
126
+
127
+ const basePath = path.join(projectDir, BASE_FILE);
128
+ if (fs.existsSync(basePath)) {
129
+ return basePath;
130
+ }
131
+ return null;
132
+ }
133
+
134
+ /**
135
+ * Check if base document exists
136
+ * @param projectDir - Project directory path
137
+ * @returns True if base document exists
138
+ * @throws {TypeError} If projectDir is not a string
139
+ */
140
+ export function hasBaseDocument(projectDir: string): boolean {
141
+ if (typeof projectDir !== 'string') {
142
+ throw new TypeError(`projectDir must be a string, got ${typeof projectDir}`);
143
+ }
144
+
145
+ return fs.existsSync(path.join(projectDir, BASE_FILE));
146
+ }
147
+
148
+ /**
149
+ * Compute text similarity between two strings using Jaccard-like coefficient
150
+ * @param text1 - First text to compare
151
+ * @param text2 - Second text to compare
152
+ * @returns Similarity score 0-1 (0 = no similarity, 1 = identical)
153
+ */
154
+ export function computeSimilarity(text1: string, text2: string): number {
155
+ if (typeof text1 !== 'string' || typeof text2 !== 'string') {
156
+ return 0;
157
+ }
158
+
159
+ const words1 = new Set(text1.toLowerCase().split(/\s+/).filter(w => w.length > MIN_WORD_LENGTH));
160
+ const words2 = text2.toLowerCase().split(/\s+/).filter(w => w.length > MIN_WORD_LENGTH);
161
+ if (words1.size === 0 || words2.length === 0) return 0;
162
+ const common = words2.filter(w => words1.has(w)).length;
163
+ return common / Math.max(words1.size, words2.length);
164
+ }
165
+
166
+ /**
167
+ * Check if base document matches reviewer document (similarity check)
168
+ */
169
+ export async function checkBaseMatch(basePath: string, reviewerPath: string): Promise<CheckMatchResult> {
170
+ try {
171
+ const { text: baseText } = await extractFromWord(basePath);
172
+ const { text: reviewerText } = await extractFromWord(reviewerPath);
173
+ const similarity = computeSimilarity(baseText, reviewerText);
174
+ return { matches: similarity > 0.5, similarity };
175
+ } catch {
176
+ return { matches: false, similarity: 0 };
177
+ }
178
+ }
179
+
180
+ /**
181
+ * Extract changes from diffs between original and modified text
182
+ * @param diffs - Array of diff changes
183
+ * @param reviewer - Reviewer identifier
184
+ */
185
+ function extractChangesFromDiffs(
186
+ diffs: Array<{ added?: boolean; removed?: boolean; value: string }>,
187
+ reviewer: string,
188
+ ): ReviewerChange[] {
189
+ const changes: ReviewerChange[] = [];
190
+ let originalPos = 0;
191
+ let i = 0;
192
+
193
+ while (i < diffs.length) {
194
+ const part = diffs[i];
195
+ if (!part) break;
196
+
197
+ if (!part.added && !part.removed) {
198
+ originalPos += part.value.length;
199
+ i++;
200
+ } else if (part.removed && diffs[i + 1]?.added) {
201
+ const nextPart = diffs[i + 1];
202
+ if (!nextPart) break;
203
+ changes.push({
204
+ reviewer,
205
+ type: 'replace',
206
+ start: originalPos,
207
+ end: originalPos + part.value.length,
208
+ oldText: part.value,
209
+ newText: nextPart.value,
210
+ });
211
+ originalPos += part.value.length;
212
+ i += 2;
213
+ } else if (part.removed) {
214
+ changes.push({
215
+ reviewer,
216
+ type: 'delete',
217
+ start: originalPos,
218
+ end: originalPos + part.value.length,
219
+ oldText: part.value,
220
+ newText: '',
221
+ });
222
+ originalPos += part.value.length;
223
+ i++;
224
+ } else if (part.added) {
225
+ changes.push({
226
+ reviewer,
227
+ type: 'insert',
228
+ start: originalPos,
229
+ end: originalPos,
230
+ oldText: '',
231
+ newText: part.value,
232
+ });
233
+ i++;
234
+ }
235
+ }
236
+
237
+ return changes;
238
+ }
239
+
240
+ /**
241
+ * Extract changes from a Word document compared to original
242
+ * Uses sentence-level diffing for better conflict detection
243
+ * @param originalText - Original text (from base document)
244
+ * @param wordText - Text extracted from reviewer's Word doc
245
+ * @param reviewer - Reviewer identifier
246
+ */
247
+ export function extractChanges(originalText: string, wordText: string, reviewer: string): ReviewerChange[] {
248
+ return extractChangesFromDiffs(diffSentences(originalText, wordText), reviewer);
249
+ }
250
+
251
+ /**
252
+ * Extract changes using word-level diff (more fine-grained)
253
+ */
254
+ export function extractChangesWordLevel(originalText: string, wordText: string, reviewer: string): ReviewerChange[] {
255
+ return extractChangesFromDiffs(diffWords(originalText, wordText), reviewer);
256
+ }
257
+
258
+ /**
259
+ * Check if two changes overlap
260
+ */
261
+ function changesOverlap(a: ReviewerChange, b: ReviewerChange): boolean {
262
+ // Insertions at same point conflict
263
+ if (a.type === 'insert' && b.type === 'insert' && a.start === b.start) {
264
+ return a.newText !== b.newText; // Same insertion is not a conflict
265
+ }
266
+
267
+ // Check range overlap
268
+ const aStart = a.start;
269
+ const aEnd = a.type === 'insert' ? a.start : a.end;
270
+ const bStart = b.start;
271
+ const bEnd = b.type === 'insert' ? b.start : b.end;
272
+
273
+ // Ranges overlap if neither ends before the other starts
274
+ if (aEnd <= bStart || bEnd <= aStart) {
275
+ return false;
276
+ }
277
+
278
+ // They overlap - but is it a conflict?
279
+ // Same change from different reviewers is not a conflict
280
+ if (a.type === b.type && a.oldText === b.oldText && a.newText === b.newText) {
281
+ return false;
282
+ }
283
+
284
+ return true;
285
+ }
286
+
287
+ /**
288
+ * Detect conflicts between changes from multiple reviewers
289
+ * @param allChanges - Array of change arrays, one per reviewer
290
+ */
291
+ export function detectConflicts(allChanges: ReviewerChange[][]): ConflictDetectionResult {
292
+ // Flatten and sort all changes by position
293
+ const flat = allChanges.flat().sort((a, b) => a.start - b.start || a.end - b.end);
294
+
295
+ const conflicts: Conflict[] = [];
296
+ const nonConflicting: ReviewerChange[] = [];
297
+ const usedIndices = new Set<number>();
298
+ let conflictId = 0;
299
+
300
+ for (let i = 0; i < flat.length; i++) {
301
+ if (usedIndices.has(i)) continue;
302
+
303
+ const change = flat[i];
304
+ if (!change) continue;
305
+ const conflictingChanges = [change];
306
+
307
+ // Find all changes that conflict with this one
308
+ for (let j = i + 1; j < flat.length; j++) {
309
+ if (usedIndices.has(j)) continue;
310
+
311
+ const other = flat[j];
312
+ if (!other) continue;
313
+
314
+ // Stop if we're past the range
315
+ if (other.start > change.end && change.type !== 'insert') break;
316
+
317
+ if (changesOverlap(change, other)) {
318
+ conflictingChanges.push(other);
319
+ usedIndices.add(j);
320
+ }
321
+ }
322
+
323
+ if (conflictingChanges.length > 1) {
324
+ // Multiple reviewers changed the same region
325
+ const start = Math.min(...conflictingChanges.map(c => c?.start ?? 0).filter(s => s !== undefined));
326
+ const end = Math.max(...conflictingChanges.map(c => c?.end ?? 0).filter(e => e !== undefined));
327
+ const firstChange = conflictingChanges[0];
328
+
329
+ conflicts.push({
330
+ id: `c${++conflictId}`,
331
+ start,
332
+ end,
333
+ original: firstChange?.oldText || '',
334
+ changes: conflictingChanges.filter((c): c is ReviewerChange => c !== undefined),
335
+ resolved: null,
336
+ });
337
+ usedIndices.add(i);
338
+ } else {
339
+ // No conflict
340
+ nonConflicting.push(change);
341
+ usedIndices.add(i);
342
+ }
343
+ }
344
+
345
+ // Deduplicate identical non-conflicting changes
346
+ const seen = new Map<string, boolean>();
347
+ const dedupedNonConflicting: ReviewerChange[] = [];
348
+
349
+ for (const change of nonConflicting) {
350
+ const key = `${change.start}:${change.end}:${change.type}:${change.newText}`;
351
+ if (!seen.has(key)) {
352
+ seen.set(key, true);
353
+ dedupedNonConflicting.push(change);
354
+ }
355
+ }
356
+
357
+ return { conflicts, nonConflicting: dedupedNonConflicting };
358
+ }
359
+
360
+ /**
361
+ * Apply non-conflicting changes to text
362
+ * @param originalText
363
+ * @param changes - Must be sorted by position
364
+ */
365
+ export function applyChanges(originalText: string, changes: ReviewerChange[]): string {
366
+ // Sort by position descending to apply from end to start
367
+ const sorted = [...changes].sort((a, b) => b.start - a.start);
368
+
369
+ let result = originalText;
370
+
371
+ for (const change of sorted) {
372
+ if (change.type === 'insert') {
373
+ result = result.slice(0, change.start) + change.newText + result.slice(change.start);
374
+ } else if (change.type === 'delete') {
375
+ result = result.slice(0, change.start) + result.slice(change.end);
376
+ } else if (change.type === 'replace') {
377
+ result = result.slice(0, change.start) + change.newText + result.slice(change.end);
378
+ }
379
+ }
380
+
381
+ return result;
382
+ }
383
+
384
+ /**
385
+ * Apply changes as CriticMarkup annotations
386
+ */
387
+ export function applyChangesAsAnnotations(originalText: string, changes: ReviewerChange[]): string {
388
+ const sorted = [...changes].sort((a, b) => b.start - a.start);
389
+
390
+ let result = originalText;
391
+
392
+ for (const change of sorted) {
393
+ if (change.type === 'insert') {
394
+ const annotation = `{++${change.newText}++}`;
395
+ result = result.slice(0, change.start) + annotation + result.slice(change.start);
396
+ } else if (change.type === 'delete') {
397
+ const annotation = `{--${change.oldText}--}`;
398
+ result = result.slice(0, change.start) + annotation + result.slice(change.end);
399
+ } else if (change.type === 'replace') {
400
+ const annotation = `{~~${change.oldText}~>${change.newText}~~}`;
401
+ result = result.slice(0, change.start) + annotation + result.slice(change.end);
402
+ }
403
+ }
404
+
405
+ return result;
406
+ }
407
+
408
+ /**
409
+ * Apply changes as git-style conflict markers
410
+ */
411
+ export function applyConflictMarkers(originalText: string, conflicts: Conflict[]): string {
412
+ // Sort by position descending
413
+ const sorted = [...conflicts].sort((a, b) => b.start - a.start);
414
+
415
+ let result = originalText;
416
+
417
+ for (const conflict of sorted) {
418
+ const markers: string[] = [];
419
+ markers.push(`<<<<<<< CONFLICT ${conflict.id}`);
420
+
421
+ for (const change of conflict.changes) {
422
+ markers.push(`======= ${change.reviewer}`);
423
+ if (change.type === 'delete') {
424
+ markers.push(`[DELETED: "${change.oldText}"]`);
425
+ } else if (change.type === 'insert') {
426
+ markers.push(change.newText);
427
+ } else {
428
+ markers.push(change.newText);
429
+ }
430
+ }
431
+
432
+ markers.push(`>>>>>>> END ${conflict.id}`);
433
+
434
+ const markerText = markers.join('\n');
435
+ result = result.slice(0, conflict.start) + markerText + result.slice(conflict.end);
436
+ }
437
+
438
+ return result;
439
+ }
440
+
441
+ /**
442
+ * Format a conflict for display
443
+ */
444
+ export function formatConflict(conflict: Conflict, originalText: string): string {
445
+ const lines: string[] = [];
446
+ const context = 50;
447
+
448
+ // Show context
449
+ const beforeStart = Math.max(0, conflict.start - context);
450
+ const afterEnd = Math.min(originalText.length, conflict.end + context);
451
+
452
+ const before = originalText.slice(beforeStart, conflict.start).trim();
453
+ const original = originalText.slice(conflict.start, conflict.end);
454
+ const after = originalText.slice(conflict.end, afterEnd).trim();
455
+
456
+ if (before) {
457
+ lines.push(` ...${before}`);
458
+ }
459
+ lines.push(` [ORIGINAL]: "${original || '(insertion point)'}"`);
460
+ if (after) {
461
+ lines.push(` ${after}...`);
462
+ }
463
+ lines.push('');
464
+ lines.push(' Options:');
465
+
466
+ conflict.changes.forEach((change, i) => {
467
+ const label = change.type === 'insert'
468
+ ? `Insert: "${change.newText.slice(0, 60)}${change.newText.length > 60 ? '...' : ''}"`
469
+ : change.type === 'delete'
470
+ ? `Delete: "${change.oldText.slice(0, 60)}${change.oldText.length > 60 ? '...' : ''}"`
471
+ : `Replace → "${change.newText.slice(0, 60)}${change.newText.length > 60 ? '...' : ''}"`;
472
+ lines.push(` ${i + 1}. [${change.reviewer}] ${label}`);
473
+ });
474
+
475
+ return lines.join('\n');
476
+ }
477
+
478
+ /**
479
+ * Save conflicts to file for later resolution
480
+ */
481
+ export function saveConflicts(projectDir: string, conflicts: Conflict[], baseDoc: string): void {
482
+ const conflictsPath = path.join(projectDir, CONFLICTS_FILE);
483
+ const data: ConflictsData = {
484
+ base: baseDoc,
485
+ merged: new Date().toISOString(),
486
+ conflicts,
487
+ };
488
+
489
+ // Ensure directory exists
490
+ const dir = path.dirname(conflictsPath);
491
+ if (!fs.existsSync(dir)) {
492
+ fs.mkdirSync(dir, { recursive: true });
493
+ }
494
+
495
+ fs.writeFileSync(conflictsPath, JSON.stringify(data, null, 2));
496
+ }
497
+
498
+ /**
499
+ * Load conflicts from file
500
+ */
501
+ export function loadConflicts(projectDir: string): ConflictsData | null {
502
+ const conflictsPath = path.join(projectDir, CONFLICTS_FILE);
503
+ if (!fs.existsSync(conflictsPath)) {
504
+ return null;
505
+ }
506
+ return JSON.parse(fs.readFileSync(conflictsPath, 'utf-8')) as ConflictsData;
507
+ }
508
+
509
+ /**
510
+ * Clear conflicts file after resolution
511
+ */
512
+ export function clearConflicts(projectDir: string): void {
513
+ const conflictsPath = path.join(projectDir, CONFLICTS_FILE);
514
+ if (fs.existsSync(conflictsPath)) {
515
+ fs.unlinkSync(conflictsPath);
516
+ }
517
+ }
518
+
519
+ /**
520
+ * Core merge logic: extract changes from reviewer docs, detect conflicts, apply annotations
521
+ */
522
+ async function mergeReviewerDocsCore(
523
+ baseText: string,
524
+ reviewerDocs: ReviewerDoc[],
525
+ options: MergeOptions = {},
526
+ ): Promise<MergeResult> {
527
+ const { diffLevel = 'sentence' } = options;
528
+
529
+ const allChanges: ReviewerChange[][] = [];
530
+ const allComments: ReviewerComment[] = [];
531
+
532
+ for (const doc of reviewerDocs) {
533
+ if (!fs.existsSync(doc.path)) {
534
+ throw new Error(`Reviewer file not found: ${doc.path}`);
535
+ }
536
+
537
+ const { text: wordText } = await extractFromWord(doc.path);
538
+
539
+ const changes = diffLevel === 'word'
540
+ ? extractChangesWordLevel(baseText, wordText, doc.name)
541
+ : extractChanges(baseText, wordText, doc.name);
542
+
543
+ allChanges.push(changes);
544
+
545
+ try {
546
+ const comments = await extractWordComments(doc.path);
547
+ allComments.push(...comments.map(c => ({ ...c, reviewer: doc.name })));
548
+ } catch (e) {
549
+ if (process.env.DEBUG) {
550
+ const error = e as Error;
551
+ console.warn(`merge: Failed to extract comments:`, error.message);
552
+ }
553
+ }
554
+ }
555
+
556
+ const { conflicts, nonConflicting } = detectConflicts(allChanges);
557
+
558
+ let merged = applyChangesAsAnnotations(baseText, nonConflicting);
559
+
560
+ for (const comment of allComments) {
561
+ merged += `\n{>>${comment.reviewer}: ${comment.text}<<}`;
562
+ }
563
+
564
+ const stats = {
565
+ reviewers: reviewerDocs.length,
566
+ totalChanges: allChanges.flat().length,
567
+ nonConflicting: nonConflicting.length,
568
+ conflicts: conflicts.length,
569
+ comments: allComments.length,
570
+ };
571
+
572
+ return { merged, conflicts, stats, originalText: baseText };
573
+ }
574
+
575
+ /**
576
+ * Merge multiple Word documents using three-way merge
577
+ */
578
+ export async function mergeThreeWay(
579
+ basePath: string,
580
+ reviewerDocs: ReviewerDoc[],
581
+ options: MergeOptions = {}
582
+ ): Promise<MergeResult & { baseText: string }> {
583
+ if (!fs.existsSync(basePath)) {
584
+ throw new Error(`Base document not found: ${basePath}`);
585
+ }
586
+
587
+ const { text: baseText } = await extractFromWord(basePath);
588
+ const result = await mergeReviewerDocsCore(baseText, reviewerDocs, options);
589
+ return { ...result, baseText };
590
+ }
591
+
592
+ /**
593
+ * Merge multiple Word documents against an original markdown file
594
+ * Legacy function - use mergeThreeWay for proper three-way merge
595
+ */
596
+ export async function mergeReviewerDocs(
597
+ originalPath: string,
598
+ reviewerDocs: ReviewerDoc[],
599
+ options: MergeOptions = {}
600
+ ): Promise<MergeResult> {
601
+ if (!fs.existsSync(originalPath)) {
602
+ throw new Error(`Original file not found: ${originalPath}`);
603
+ }
604
+
605
+ const originalText = fs.readFileSync(originalPath, 'utf-8');
606
+ return mergeReviewerDocsCore(originalText, reviewerDocs, options);
607
+ }
608
+
609
+ /**
610
+ * Resolve a conflict by choosing one option
611
+ * @param conflict
612
+ * @param choice - Index of chosen change (0-based)
613
+ */
614
+ export function resolveConflict(conflict: Conflict, choice: number): ReviewerChange {
615
+ if (choice < 0 || choice >= conflict.changes.length) {
616
+ throw new Error(`Invalid choice: ${choice}. Must be 0-${conflict.changes.length - 1}`);
617
+ }
618
+ const selectedChange = conflict.changes[choice];
619
+ if (!selectedChange) {
620
+ throw new Error(`Invalid choice: ${choice}. Change not found`);
621
+ }
622
+ conflict.resolved = selectedChange.reviewer;
623
+ return selectedChange;
624
+ }
625
+
626
+ /**
627
+ * Get list of unresolved conflicts
628
+ */
629
+ export function getUnresolvedConflicts(projectDir: string): Conflict[] {
630
+ const data = loadConflicts(projectDir);
631
+ if (!data) return [];
632
+ return data.conflicts.filter(c => c.resolved === null);
633
+ }