docrev 0.9.11 → 0.9.14

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 (138) 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 +391 -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 +50 -1
  33. package/dist/lib/build.d.ts.map +1 -1
  34. package/dist/lib/build.js +80 -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/import.d.ts.map +1 -1
  43. package/dist/lib/import.js +146 -24
  44. package/dist/lib/import.js.map +1 -1
  45. package/dist/lib/pdf-comments.js +44 -44
  46. package/dist/lib/plugins.js +57 -57
  47. package/dist/lib/pptx-themes.js +115 -115
  48. package/dist/lib/spelling.js +2 -2
  49. package/dist/lib/templates.js +387 -387
  50. package/dist/lib/themes.js +51 -51
  51. package/dist/lib/types.d.ts +20 -0
  52. package/dist/lib/types.d.ts.map +1 -1
  53. package/dist/lib/word-extraction.d.ts +6 -0
  54. package/dist/lib/word-extraction.d.ts.map +1 -1
  55. package/dist/lib/word-extraction.js +46 -3
  56. package/dist/lib/word-extraction.js.map +1 -1
  57. package/dist/lib/wordcomments.d.ts.map +1 -1
  58. package/dist/lib/wordcomments.js +23 -5
  59. package/dist/lib/wordcomments.js.map +1 -1
  60. package/eslint.config.js +27 -27
  61. package/lib/anchor-match.ts +276 -276
  62. package/lib/annotations.ts +644 -644
  63. package/lib/build.ts +1300 -1227
  64. package/lib/citations.ts +160 -160
  65. package/lib/commands/build.ts +833 -801
  66. package/lib/commands/citations.ts +515 -515
  67. package/lib/commands/comments.ts +1050 -1050
  68. package/lib/commands/context.ts +174 -174
  69. package/lib/commands/core.ts +309 -309
  70. package/lib/commands/doi.ts +435 -435
  71. package/lib/commands/file-ops.ts +372 -372
  72. package/lib/commands/history.ts +320 -320
  73. package/lib/commands/index.ts +87 -87
  74. package/lib/commands/init.ts +259 -259
  75. package/lib/commands/merge-resolve.ts +378 -378
  76. package/lib/commands/preview.ts +178 -178
  77. package/lib/commands/project-info.ts +244 -244
  78. package/lib/commands/quality.ts +517 -517
  79. package/lib/commands/response.ts +454 -454
  80. package/lib/commands/section-boundaries.ts +82 -82
  81. package/lib/commands/sections.ts +451 -451
  82. package/lib/commands/sync.ts +706 -706
  83. package/lib/commands/text-ops.ts +449 -449
  84. package/lib/commands/utilities.ts +448 -448
  85. package/lib/commands/verify-anchors.ts +272 -272
  86. package/lib/commands/word-tools.ts +340 -340
  87. package/lib/comment-realign.ts +517 -517
  88. package/lib/config.ts +84 -84
  89. package/lib/crossref.ts +781 -781
  90. package/lib/csl.ts +191 -191
  91. package/lib/dependencies.ts +98 -98
  92. package/lib/diff-engine.ts +465 -465
  93. package/lib/doi-cache.ts +115 -115
  94. package/lib/doi.ts +897 -897
  95. package/lib/equations.ts +506 -506
  96. package/lib/errors.ts +346 -346
  97. package/lib/format.ts +541 -541
  98. package/lib/git.ts +326 -326
  99. package/lib/grammar.ts +303 -303
  100. package/lib/image-registry.ts +180 -180
  101. package/lib/import.ts +911 -792
  102. package/lib/journals.ts +543 -543
  103. package/lib/merge.ts +633 -633
  104. package/lib/orcid.ts +144 -144
  105. package/lib/pdf-comments.ts +263 -263
  106. package/lib/pdf-import.ts +524 -524
  107. package/lib/plugins.ts +362 -362
  108. package/lib/postprocess.ts +188 -188
  109. package/lib/pptx-color-filter.lua +37 -37
  110. package/lib/pptx-template.ts +469 -469
  111. package/lib/pptx-themes.ts +483 -483
  112. package/lib/protect-restore.ts +520 -520
  113. package/lib/rate-limiter.ts +94 -94
  114. package/lib/response.ts +197 -197
  115. package/lib/restore-references.ts +240 -240
  116. package/lib/review.ts +327 -327
  117. package/lib/schema.ts +417 -417
  118. package/lib/scientific-words.ts +73 -73
  119. package/lib/sections.ts +335 -335
  120. package/lib/slides.ts +756 -756
  121. package/lib/spelling.ts +334 -334
  122. package/lib/templates.ts +526 -526
  123. package/lib/themes.ts +742 -742
  124. package/lib/trackchanges.ts +247 -247
  125. package/lib/tui.ts +450 -450
  126. package/lib/types.ts +550 -530
  127. package/lib/undo.ts +250 -250
  128. package/lib/utils.ts +69 -69
  129. package/lib/variables.ts +179 -179
  130. package/lib/word-extraction.ts +806 -759
  131. package/lib/word.ts +643 -643
  132. package/lib/wordcomments.ts +817 -798
  133. package/package.json +137 -137
  134. package/scripts/postbuild.js +28 -28
  135. package/skill/REFERENCE.md +431 -431
  136. package/skill/SKILL.md +258 -258
  137. package/tsconfig.json +26 -26
  138. 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
+ }