docrev 0.11.0 → 0.11.2

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 (123) hide show
  1. package/.gitattributes +1 -1
  2. package/CHANGELOG.md +202 -184
  3. package/PLAN-tables-and-postprocess.md +850 -850
  4. package/README.md +433 -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/build.d.ts +80 -0
  11. package/dist/lib/build.d.ts.map +1 -1
  12. package/dist/lib/build.js +207 -4
  13. package/dist/lib/build.js.map +1 -1
  14. package/dist/lib/commands/build.d.ts.map +1 -1
  15. package/dist/lib/commands/build.js +34 -12
  16. package/dist/lib/commands/build.js.map +1 -1
  17. package/dist/lib/commands/utilities.js +164 -164
  18. package/dist/lib/commands/word-tools.js +8 -8
  19. package/dist/lib/grammar.js +3 -3
  20. package/dist/lib/macro-filter.lua +201 -201
  21. package/dist/lib/pdf-comments.js +44 -44
  22. package/dist/lib/plugins.js +57 -57
  23. package/dist/lib/pptx-color-filter.lua +37 -37
  24. package/dist/lib/pptx-themes.js +115 -115
  25. package/dist/lib/schema.d.ts.map +1 -1
  26. package/dist/lib/schema.js +8 -2
  27. package/dist/lib/schema.js.map +1 -1
  28. package/dist/lib/spelling.js +2 -2
  29. package/dist/lib/templates.js +387 -387
  30. package/dist/lib/themes.js +51 -51
  31. package/dist/lib/trackchanges.d.ts +41 -25
  32. package/dist/lib/trackchanges.d.ts.map +1 -1
  33. package/dist/lib/trackchanges.js +90 -151
  34. package/dist/lib/trackchanges.js.map +1 -1
  35. package/dist/lib/types.d.ts +0 -7
  36. package/dist/lib/types.d.ts.map +1 -1
  37. package/docs-src/build.py +113 -113
  38. package/docs-src/extra.css +208 -208
  39. package/docs-src/md-to-html.lua +6 -6
  40. package/docs-src/template.html +116 -116
  41. package/eslint.config.js +27 -27
  42. package/lib/anchor-match.ts +307 -307
  43. package/lib/annotations.ts +664 -664
  44. package/lib/build.ts +2047 -1770
  45. package/lib/citations.ts +160 -160
  46. package/lib/commands/build.ts +885 -860
  47. package/lib/commands/citations.ts +515 -515
  48. package/lib/commands/comments.ts +1050 -1050
  49. package/lib/commands/context.ts +176 -176
  50. package/lib/commands/core.ts +309 -309
  51. package/lib/commands/doi.ts +451 -451
  52. package/lib/commands/file-ops.ts +372 -372
  53. package/lib/commands/history.ts +320 -320
  54. package/lib/commands/index.ts +87 -87
  55. package/lib/commands/init.ts +259 -259
  56. package/lib/commands/merge-resolve.ts +378 -378
  57. package/lib/commands/preview.ts +178 -178
  58. package/lib/commands/project-info.ts +244 -244
  59. package/lib/commands/quality.ts +517 -517
  60. package/lib/commands/response.ts +454 -454
  61. package/lib/commands/section-boundaries.ts +82 -82
  62. package/lib/commands/sections.ts +451 -451
  63. package/lib/commands/sync.ts +689 -689
  64. package/lib/commands/text-ops.ts +449 -449
  65. package/lib/commands/utilities.ts +448 -448
  66. package/lib/commands/verify-anchors.ts +272 -272
  67. package/lib/commands/word-tools.ts +340 -340
  68. package/lib/comment-realign.ts +99 -99
  69. package/lib/config.ts +84 -84
  70. package/lib/crossref.ts +781 -781
  71. package/lib/csl.ts +191 -191
  72. package/lib/dependencies.ts +132 -132
  73. package/lib/diff-engine.ts +465 -465
  74. package/lib/doi-cache.ts +115 -115
  75. package/lib/doi.ts +879 -879
  76. package/lib/equations.ts +506 -506
  77. package/lib/errors.ts +350 -350
  78. package/lib/format.ts +541 -541
  79. package/lib/git.ts +326 -326
  80. package/lib/grammar.ts +303 -303
  81. package/lib/image-registry.ts +180 -180
  82. package/lib/import.ts +906 -906
  83. package/lib/journals.ts +543 -543
  84. package/lib/macro-filter.lua +201 -201
  85. package/lib/macros.ts +273 -273
  86. package/lib/merge.ts +633 -633
  87. package/lib/ooxml.ts +768 -768
  88. package/lib/orcid.ts +144 -144
  89. package/lib/pdf-comments.ts +263 -263
  90. package/lib/pdf-import.ts +524 -524
  91. package/lib/plugins.ts +362 -362
  92. package/lib/postprocess.ts +188 -188
  93. package/lib/pptx-color-filter.lua +37 -37
  94. package/lib/pptx-template.ts +469 -469
  95. package/lib/pptx-themes.ts +483 -483
  96. package/lib/protect-restore.ts +520 -520
  97. package/lib/rate-limiter.ts +127 -127
  98. package/lib/response.ts +197 -197
  99. package/lib/restore-references.ts +240 -240
  100. package/lib/review.ts +327 -327
  101. package/lib/schema.ts +494 -488
  102. package/lib/scientific-words.ts +73 -73
  103. package/lib/sections.ts +428 -428
  104. package/lib/slides.ts +756 -756
  105. package/lib/spelling.ts +334 -334
  106. package/lib/templates.ts +526 -526
  107. package/lib/themes.ts +742 -742
  108. package/lib/trackchanges.ts +166 -247
  109. package/lib/tui.ts +450 -450
  110. package/lib/types.ts +546 -558
  111. package/lib/undo.ts +250 -250
  112. package/lib/utils.ts +69 -69
  113. package/lib/variables.ts +179 -179
  114. package/lib/word-extraction.ts +525 -525
  115. package/lib/word.ts +526 -526
  116. package/lib/wordcomments.ts +789 -789
  117. package/mkdocs.yml +64 -64
  118. package/package.json +137 -137
  119. package/scripts/postbuild.js +47 -47
  120. package/skill/REFERENCE.md +550 -539
  121. package/skill/SKILL.md +302 -295
  122. package/tsconfig.json +26 -26
  123. package/types/index.d.ts +531 -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
+ }