docrev 0.9.13 → 0.9.15

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 (126) hide show
  1. package/.claude/settings.local.json +9 -9
  2. package/.gitattributes +1 -1
  3. package/CHANGELOG.md +149 -149
  4. package/PLAN-tables-and-postprocess.md +850 -850
  5. package/README.md +411 -391
  6. package/bin/rev.js +11 -11
  7. package/bin/rev.ts +145 -145
  8. package/completions/rev.bash +127 -127
  9. package/completions/rev.ps1 +210 -210
  10. package/completions/rev.zsh +207 -207
  11. package/dev_notes/stress2/build_adversarial.ts +186 -186
  12. package/dev_notes/stress2/drift_matcher.ts +62 -62
  13. package/dev_notes/stress2/probe_anchors.ts +35 -35
  14. package/dev_notes/stress2/project/discussion.before.md +3 -3
  15. package/dev_notes/stress2/project/discussion.md +3 -3
  16. package/dev_notes/stress2/project/methods.before.md +20 -20
  17. package/dev_notes/stress2/project/methods.md +20 -20
  18. package/dev_notes/stress2/project/rev.yaml +5 -5
  19. package/dev_notes/stress2/project/sections.yaml +4 -4
  20. package/dev_notes/stress2/sections.yaml +5 -5
  21. package/dev_notes/stress2/trace_placement.ts +50 -50
  22. package/dev_notes/stresstest_boundaries.ts +27 -27
  23. package/dev_notes/stresstest_drift_apply.ts +43 -43
  24. package/dev_notes/stresstest_drift_compare.ts +43 -43
  25. package/dev_notes/stresstest_drift_v2.ts +54 -54
  26. package/dev_notes/stresstest_inspect.ts +54 -54
  27. package/dev_notes/stresstest_pstyle.ts +55 -55
  28. package/dev_notes/stresstest_section_debug.ts +23 -23
  29. package/dev_notes/stresstest_split.ts +70 -70
  30. package/dev_notes/stresstest_trace.ts +19 -19
  31. package/dev_notes/stresstest_verify_no_overwrite.ts +40 -40
  32. package/dist/lib/build.d.ts +38 -1
  33. package/dist/lib/build.d.ts.map +1 -1
  34. package/dist/lib/build.js +68 -30
  35. package/dist/lib/build.js.map +1 -1
  36. package/dist/lib/commands/build.d.ts.map +1 -1
  37. package/dist/lib/commands/build.js +38 -5
  38. package/dist/lib/commands/build.js.map +1 -1
  39. package/dist/lib/commands/utilities.js +164 -164
  40. package/dist/lib/commands/word-tools.js +8 -8
  41. package/dist/lib/grammar.js +3 -3
  42. package/dist/lib/pdf-comments.js +44 -44
  43. package/dist/lib/plugins.js +57 -57
  44. package/dist/lib/pptx-themes.js +115 -115
  45. package/dist/lib/spelling.js +2 -2
  46. package/dist/lib/templates.js +387 -387
  47. package/dist/lib/themes.js +51 -51
  48. package/eslint.config.js +27 -27
  49. package/lib/anchor-match.ts +276 -276
  50. package/lib/annotations.ts +644 -644
  51. package/lib/build.ts +1300 -1251
  52. package/lib/citations.ts +160 -160
  53. package/lib/commands/build.ts +833 -801
  54. package/lib/commands/citations.ts +515 -515
  55. package/lib/commands/comments.ts +1050 -1050
  56. package/lib/commands/context.ts +174 -174
  57. package/lib/commands/core.ts +309 -309
  58. package/lib/commands/doi.ts +435 -435
  59. package/lib/commands/file-ops.ts +372 -372
  60. package/lib/commands/history.ts +320 -320
  61. package/lib/commands/index.ts +87 -87
  62. package/lib/commands/init.ts +259 -259
  63. package/lib/commands/merge-resolve.ts +378 -378
  64. package/lib/commands/preview.ts +178 -178
  65. package/lib/commands/project-info.ts +244 -244
  66. package/lib/commands/quality.ts +517 -517
  67. package/lib/commands/response.ts +454 -454
  68. package/lib/commands/section-boundaries.ts +82 -82
  69. package/lib/commands/sections.ts +451 -451
  70. package/lib/commands/sync.ts +706 -706
  71. package/lib/commands/text-ops.ts +449 -449
  72. package/lib/commands/utilities.ts +448 -448
  73. package/lib/commands/verify-anchors.ts +272 -272
  74. package/lib/commands/word-tools.ts +340 -340
  75. package/lib/comment-realign.ts +517 -517
  76. package/lib/config.ts +84 -84
  77. package/lib/crossref.ts +781 -781
  78. package/lib/csl.ts +191 -191
  79. package/lib/dependencies.ts +98 -98
  80. package/lib/diff-engine.ts +465 -465
  81. package/lib/doi-cache.ts +115 -115
  82. package/lib/doi.ts +897 -897
  83. package/lib/equations.ts +506 -506
  84. package/lib/errors.ts +346 -346
  85. package/lib/format.ts +541 -541
  86. package/lib/git.ts +326 -326
  87. package/lib/grammar.ts +303 -303
  88. package/lib/image-registry.ts +180 -180
  89. package/lib/import.ts +911 -911
  90. package/lib/journals.ts +543 -543
  91. package/lib/merge.ts +633 -633
  92. package/lib/orcid.ts +144 -144
  93. package/lib/pdf-comments.ts +263 -263
  94. package/lib/pdf-import.ts +524 -524
  95. package/lib/plugins.ts +362 -362
  96. package/lib/postprocess.ts +188 -188
  97. package/lib/pptx-color-filter.lua +37 -37
  98. package/lib/pptx-template.ts +469 -469
  99. package/lib/pptx-themes.ts +483 -483
  100. package/lib/protect-restore.ts +520 -520
  101. package/lib/rate-limiter.ts +94 -94
  102. package/lib/response.ts +197 -197
  103. package/lib/restore-references.ts +240 -240
  104. package/lib/review.ts +327 -327
  105. package/lib/schema.ts +417 -417
  106. package/lib/scientific-words.ts +73 -73
  107. package/lib/sections.ts +335 -335
  108. package/lib/slides.ts +756 -756
  109. package/lib/spelling.ts +334 -334
  110. package/lib/templates.ts +526 -526
  111. package/lib/themes.ts +742 -742
  112. package/lib/trackchanges.ts +247 -247
  113. package/lib/tui.ts +450 -450
  114. package/lib/types.ts +550 -550
  115. package/lib/undo.ts +250 -250
  116. package/lib/utils.ts +69 -69
  117. package/lib/variables.ts +179 -179
  118. package/lib/word-extraction.ts +806 -806
  119. package/lib/word.ts +643 -643
  120. package/lib/wordcomments.ts +817 -817
  121. package/package.json +137 -137
  122. package/scripts/postbuild.js +28 -28
  123. package/skill/REFERENCE.md +473 -431
  124. package/skill/SKILL.md +274 -258
  125. package/tsconfig.json +26 -26
  126. 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
+ }