docrev 0.7.8 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/build.js CHANGED
@@ -213,7 +213,7 @@ export function combineSections(directory, config, options = {}) {
213
213
  // Resolve forward references (refs that appear before their anchor definition)
214
214
  // This fixes pandoc-crossref limitation with multi-file documents
215
215
  if (hasPandocCrossref()) {
216
- const registry = buildRegistry(directory);
216
+ const registry = buildRegistry(directory, sections);
217
217
  const { text, resolved } = resolveForwardRefs(paperContent, registry);
218
218
  if (resolved.length > 0) {
219
219
  paperContent = text;
@@ -280,7 +280,8 @@ export function prepareForFormat(paperPath, format, config, options = {}) {
280
280
  let content = fs.readFileSync(paperPath, 'utf-8');
281
281
 
282
282
  // Build crossref registry for reference conversion
283
- const registry = buildRegistry(directory);
283
+ // Pass sections from config to ensure correct file ordering
284
+ const registry = buildRegistry(directory, config.sections);
284
285
 
285
286
  if (format === 'pdf' || format === 'tex') {
286
287
  // Strip all annotations for clean output
@@ -704,6 +704,21 @@ export function register(program, pkg) {
704
704
  }
705
705
  }
706
706
 
707
+ // Store base document for three-way merge (only for DOCX, not dual)
708
+ const docxResult = results.find(r => r.format === 'docx' && r.success);
709
+ if (docxResult && !options.dual) {
710
+ try {
711
+ const { storeBaseDocument } = await import('../merge.js');
712
+ storeBaseDocument(dir, docxResult.outputPath);
713
+ console.log(chalk.dim(`\n Saved as .rev/base.docx for merge`));
714
+ } catch (err) {
715
+ // Non-fatal - just log if DEBUG
716
+ if (process.env.DEBUG) {
717
+ console.log(chalk.dim(`\n Could not store base document: ${err.message}`));
718
+ }
719
+ }
720
+ }
721
+
707
722
  console.log(chalk.green('\nBuild complete!'));
708
723
  } catch (err) {
709
724
  spin.stop();
@@ -727,26 +727,31 @@ export function register(program) {
727
727
  });
728
728
 
729
729
  // ==========================================================================
730
- // MERGE command - Combine feedback from multiple reviewers
730
+ // MERGE command - Combine feedback from multiple reviewers (three-way merge)
731
731
  // ==========================================================================
732
732
 
733
733
  program
734
734
  .command('merge')
735
- .description('Merge feedback from multiple Word documents')
736
- .argument('<original>', 'Original markdown file')
735
+ .description('Merge feedback from multiple Word documents using three-way merge')
737
736
  .argument('<docx...>', 'Word documents from reviewers')
738
- .option('-o, --output <file>', 'Output file (default: original-merged.md)')
737
+ .option('-b, --base <file>', 'Base document (original sent to reviewers). Auto-detected if not specified.')
738
+ .option('-o, --output <file>', 'Output file (default: writes to section files)')
739
739
  .option('--names <names>', 'Reviewer names (comma-separated, in order of docx files)')
740
- .option('--auto', 'Auto-resolve conflicts by taking first change')
740
+ .option('--strategy <strategy>', 'Conflict resolution: first, latest, or interactive (default)', 'interactive')
741
+ .option('--diff-level <level>', 'Diff granularity: sentence or word (default: sentence)', 'sentence')
741
742
  .option('--dry-run', 'Show conflicts without writing')
742
- .action(async (original, docxFiles, options) => {
743
- const { mergeReviewerDocs, formatConflict, resolveConflict } = await import('../merge.js');
744
-
745
- if (!fs.existsSync(original)) {
746
- console.error(fmt.status('error', `Original file not found: ${original}`));
747
- process.exit(1);
748
- }
749
-
743
+ .option('--sections', 'Split merged output back to section files')
744
+ .action(async (docxFiles, options) => {
745
+ const {
746
+ mergeThreeWay,
747
+ formatConflict,
748
+ resolveConflict,
749
+ getBaseDocument,
750
+ checkBaseMatch,
751
+ saveConflicts,
752
+ } = await import('../merge.js');
753
+
754
+ // Validate reviewer files exist
750
755
  for (const docx of docxFiles) {
751
756
  if (!fs.existsSync(docx)) {
752
757
  console.error(fmt.status('error', `Reviewer file not found: ${docx}`));
@@ -754,14 +759,50 @@ export function register(program) {
754
759
  }
755
760
  }
756
761
 
762
+ // Determine base document
763
+ let basePath = options.base;
764
+ let baseSource = 'specified';
765
+
766
+ if (!basePath) {
767
+ // Try to use .rev/base.docx
768
+ const projectDir = process.cwd();
769
+ basePath = getBaseDocument(projectDir);
770
+
771
+ if (basePath) {
772
+ baseSource = 'auto (.rev/base.docx)';
773
+ } else {
774
+ console.log(chalk.yellow('\n No base document found in .rev/base.docx'));
775
+ console.log(chalk.dim(' Tip: Run "rev build docx" to automatically save the base document.\n'));
776
+ console.error(fmt.status('error', 'Base document required. Use --base <file.docx>'));
777
+ process.exit(1);
778
+ }
779
+ }
780
+
781
+ if (!fs.existsSync(basePath)) {
782
+ console.error(fmt.status('error', `Base document not found: ${basePath}`));
783
+ process.exit(1);
784
+ }
785
+
786
+ // Check similarity between base and reviewer docs
787
+ const { matches, similarity } = await checkBaseMatch(basePath, docxFiles[0]);
788
+ if (!matches) {
789
+ console.log(chalk.yellow(`\n Warning: Base document may not match reviewer file (${Math.round(similarity * 100)}% similar)`));
790
+ console.log(chalk.dim(' If this is wrong, use --base to specify the correct original document.\n'));
791
+ }
792
+
793
+ // Parse reviewer names
757
794
  const names = options.names
758
795
  ? options.names.split(',').map(n => n.trim())
759
- : docxFiles.map((f, i) => `Reviewer ${i + 1}`);
796
+ : docxFiles.map((f, i) => {
797
+ // Try to extract name from filename (e.g., paper_reviewer_A.docx)
798
+ const basename = path.basename(f, '.docx');
799
+ const match = basename.match(/_([A-Za-z]+)$/);
800
+ return match ? match[1] : `Reviewer ${i + 1}`;
801
+ });
760
802
 
761
- if (names.length < docxFiles.length) {
762
- for (let i = names.length; i < docxFiles.length; i++) {
763
- names.push(`Reviewer ${i + 1}`);
764
- }
803
+ // Pad names if needed
804
+ while (names.length < docxFiles.length) {
805
+ names.push(`Reviewer ${names.length + 1}`);
765
806
  }
766
807
 
767
808
  const reviewerDocs = docxFiles.map((p, i) => ({
@@ -769,21 +810,23 @@ export function register(program) {
769
810
  name: names[i],
770
811
  }));
771
812
 
772
- console.log(fmt.header('Multi-Reviewer Merge'));
813
+ console.log(fmt.header('Three-Way Merge'));
773
814
  console.log();
774
- console.log(chalk.dim(` Original: ${original}`));
815
+ console.log(chalk.dim(` Base: ${path.basename(basePath)} (${baseSource})`));
775
816
  console.log(chalk.dim(` Reviewers: ${names.join(', ')}`));
817
+ console.log(chalk.dim(` Diff level: ${options.diffLevel}`));
776
818
  console.log();
777
819
 
778
820
  const spin = fmt.spinner('Analyzing changes...').start();
779
821
 
780
822
  try {
781
- const { merged, conflicts, stats, originalText } = await mergeReviewerDocs(original, reviewerDocs, {
782
- autoResolve: options.auto,
823
+ const { merged, conflicts, stats, baseText } = await mergeThreeWay(basePath, reviewerDocs, {
824
+ diffLevel: options.diffLevel,
783
825
  });
784
826
 
785
827
  spin.stop();
786
828
 
829
+ // Display stats
787
830
  console.log(fmt.table(['Metric', 'Count'], [
788
831
  ['Total changes', stats.totalChanges.toString()],
789
832
  ['Non-conflicting', stats.nonConflicting.toString()],
@@ -792,21 +835,33 @@ export function register(program) {
792
835
  ]));
793
836
  console.log();
794
837
 
838
+ let finalMerged = merged;
839
+
840
+ // Handle conflicts
795
841
  if (conflicts.length > 0) {
796
842
  console.log(chalk.yellow(`Found ${conflicts.length} conflict(s):\n`));
797
843
 
798
- let resolvedMerged = merged;
799
-
800
- for (let i = 0; i < conflicts.length; i++) {
801
- const conflict = conflicts[i];
802
- console.log(chalk.bold(`Conflict ${i + 1}/${conflicts.length}:`));
803
- console.log(formatConflict(conflict, originalText));
804
- console.log();
844
+ if (options.strategy === 'first') {
845
+ // Auto-resolve: take first reviewer's change
846
+ for (const conflict of conflicts) {
847
+ console.log(chalk.dim(` Conflict ${conflict.id}: using ${conflict.changes[0].reviewer}'s change`));
848
+ resolveConflict(conflict, 0);
849
+ }
850
+ } else if (options.strategy === 'latest') {
851
+ // Auto-resolve: take last reviewer's change
852
+ for (const conflict of conflicts) {
853
+ const lastIdx = conflict.changes.length - 1;
854
+ console.log(chalk.dim(` Conflict ${conflict.id}: using ${conflict.changes[lastIdx].reviewer}'s change`));
855
+ resolveConflict(conflict, lastIdx);
856
+ }
857
+ } else if (!options.dryRun) {
858
+ // Interactive resolution
859
+ for (let i = 0; i < conflicts.length; i++) {
860
+ const conflict = conflicts[i];
861
+ console.log(chalk.bold(`\nConflict ${i + 1}/${conflicts.length} (${conflict.id}):`));
862
+ console.log(formatConflict(conflict, baseText));
863
+ console.log();
805
864
 
806
- if (options.auto) {
807
- console.log(chalk.dim(` Auto-resolved: using ${conflict.changes[0].reviewer}'s change`));
808
- resolvedMerged = resolveConflict(resolvedMerged, conflict, 0, originalText);
809
- } else if (!options.dryRun) {
810
865
  const rl = await import('readline');
811
866
  const readline = rl.createInterface({
812
867
  input: process.stdin,
@@ -821,36 +876,50 @@ export function register(program) {
821
876
  if (answer.toLowerCase() !== 's' && !isNaN(parseInt(answer))) {
822
877
  const choice = parseInt(answer) - 1;
823
878
  if (choice >= 0 && choice < conflict.changes.length) {
824
- resolvedMerged = resolveConflict(resolvedMerged, conflict, choice, originalText);
825
- console.log(chalk.green(` Applied: ${conflict.changes[choice].reviewer}'s change`));
879
+ resolveConflict(conflict, choice);
880
+ console.log(chalk.green(` Applied: ${conflict.changes[choice].reviewer}'s change`));
826
881
  }
827
882
  } else {
828
- console.log(chalk.dim(' Skipped'));
883
+ console.log(chalk.dim(' Skipped (will need manual resolution)'));
829
884
  }
830
- console.log();
831
885
  }
832
886
  }
833
887
 
834
- if (!options.dryRun) {
835
- const outPath = options.output || original.replace(/\.md$/, '-merged.md');
836
- fs.writeFileSync(outPath, resolvedMerged, 'utf-8');
837
- console.log(fmt.status('success', `Merged output written to ${outPath}`));
888
+ // Save unresolved conflicts for later
889
+ const unresolved = conflicts.filter(c => c.resolved === null);
890
+ if (unresolved.length > 0) {
891
+ saveConflicts(process.cwd(), conflicts, basePath);
892
+ console.log(chalk.yellow(`\n ${unresolved.length} unresolved conflict(s) saved to .rev/conflicts.json`));
893
+ console.log(chalk.dim(' Run "rev conflicts" to view, "rev merge-resolve" to resolve'));
838
894
  }
839
- } else {
840
- if (!options.dryRun) {
841
- const outPath = options.output || original.replace(/\.md$/, '-merged.md');
842
- fs.writeFileSync(outPath, merged, 'utf-8');
843
- console.log(fmt.status('success', `Merged output written to ${outPath}`));
895
+ }
896
+
897
+ // Write output
898
+ if (!options.dryRun) {
899
+ if (options.output) {
900
+ // Write to single file
901
+ fs.writeFileSync(options.output, finalMerged, 'utf-8');
902
+ console.log(fmt.status('success', `Merged output written to ${options.output}`));
903
+ } else if (options.sections) {
904
+ // Split to section files (TODO: implement section splitting)
905
+ console.log(chalk.yellow(' Section splitting not yet implemented'));
906
+ console.log(chalk.dim(' Use -o to specify output file'));
844
907
  } else {
845
- console.log(fmt.status('info', 'Dry run - no output written'));
908
+ // Default: write to merged.md
909
+ const outPath = 'merged.md';
910
+ fs.writeFileSync(outPath, finalMerged, 'utf-8');
911
+ console.log(fmt.status('success', `Merged output written to ${outPath}`));
846
912
  }
847
- }
848
913
 
849
- if (!options.dryRun && stats.nonConflicting > 0) {
850
914
  console.log();
851
915
  console.log(chalk.dim('Next steps:'));
852
- console.log(chalk.dim(' 1. rev review <merged.md> - Review all changes'));
853
- console.log(chalk.dim(' 2. rev comments <merged.md> - Address comments'));
916
+ console.log(chalk.dim(' 1. rev review merged.md - Accept/reject changes'));
917
+ console.log(chalk.dim(' 2. rev comments merged.md - Address comments'));
918
+ if (conflicts.some(c => c.resolved === null)) {
919
+ console.log(chalk.dim(' 3. rev merge-resolve - Resolve remaining conflicts'));
920
+ }
921
+ } else {
922
+ console.log(fmt.status('info', 'Dry run - no output written'));
854
923
  }
855
924
  } catch (err) {
856
925
  spin.stop();
@@ -859,4 +928,151 @@ export function register(program) {
859
928
  process.exit(1);
860
929
  }
861
930
  });
931
+
932
+ // ==========================================================================
933
+ // CONFLICTS command - List unresolved conflicts
934
+ // ==========================================================================
935
+
936
+ program
937
+ .command('conflicts')
938
+ .description('List unresolved merge conflicts')
939
+ .action(async () => {
940
+ const { loadConflicts, formatConflict } = await import('../merge.js');
941
+ const projectDir = process.cwd();
942
+ const data = loadConflicts(projectDir);
943
+
944
+ if (!data) {
945
+ console.log(fmt.status('info', 'No conflicts file found'));
946
+ return;
947
+ }
948
+
949
+ const unresolved = data.conflicts.filter(c => c.resolved === null);
950
+
951
+ if (unresolved.length === 0) {
952
+ console.log(fmt.status('success', 'All conflicts resolved!'));
953
+ return;
954
+ }
955
+
956
+ console.log(fmt.header(`Unresolved Conflicts (${unresolved.length})`));
957
+ console.log();
958
+ console.log(chalk.dim(` Base: ${data.base}`));
959
+ console.log(chalk.dim(` Merged: ${data.merged}`));
960
+ console.log();
961
+
962
+ for (const conflict of unresolved) {
963
+ console.log(chalk.bold(`Conflict ${conflict.id}:`));
964
+ // Show abbreviated info
965
+ console.log(chalk.dim(` Original: "${conflict.original.slice(0, 50)}${conflict.original.length > 50 ? '...' : ''}"`));
966
+ console.log(chalk.dim(` Options: ${conflict.changes.map(c => c.reviewer).join(', ')}`));
967
+ console.log();
968
+ }
969
+
970
+ console.log(chalk.dim('Run "rev merge-resolve" to resolve conflicts interactively'));
971
+ });
972
+
973
+ // ==========================================================================
974
+ // MERGE-RESOLVE command - Interactively resolve merge conflicts
975
+ // ==========================================================================
976
+
977
+ program
978
+ .command('merge-resolve')
979
+ .alias('mresolve')
980
+ .description('Resolve merge conflicts interactively')
981
+ .option('--theirs', 'Accept all changes from last reviewer')
982
+ .option('--ours', 'Accept all changes from first reviewer')
983
+ .action(async (options) => {
984
+ const { loadConflicts, saveConflicts, clearConflicts, resolveConflict, formatConflict } = await import('../merge.js');
985
+ const projectDir = process.cwd();
986
+ const data = loadConflicts(projectDir);
987
+
988
+ if (!data) {
989
+ console.log(fmt.status('info', 'No conflicts to resolve'));
990
+ return;
991
+ }
992
+
993
+ const unresolved = data.conflicts.filter(c => c.resolved === null);
994
+
995
+ if (unresolved.length === 0) {
996
+ console.log(fmt.status('success', 'All conflicts already resolved!'));
997
+ clearConflicts(projectDir);
998
+ return;
999
+ }
1000
+
1001
+ console.log(fmt.header(`Resolving ${unresolved.length} Conflict(s)`));
1002
+ console.log();
1003
+
1004
+ if (options.theirs) {
1005
+ // Accept all from last reviewer
1006
+ for (const conflict of unresolved) {
1007
+ const lastIdx = conflict.changes.length - 1;
1008
+ resolveConflict(conflict, lastIdx);
1009
+ console.log(chalk.dim(` ${conflict.id}: accepted ${conflict.changes[lastIdx].reviewer}'s change`));
1010
+ }
1011
+ saveConflicts(projectDir, data.conflicts, data.base);
1012
+ console.log(fmt.status('success', `Resolved ${unresolved.length} conflicts (--theirs)`));
1013
+ } else if (options.ours) {
1014
+ // Accept all from first reviewer
1015
+ for (const conflict of unresolved) {
1016
+ resolveConflict(conflict, 0);
1017
+ console.log(chalk.dim(` ${conflict.id}: accepted ${conflict.changes[0].reviewer}'s change`));
1018
+ }
1019
+ saveConflicts(projectDir, data.conflicts, data.base);
1020
+ console.log(fmt.status('success', `Resolved ${unresolved.length} conflicts (--ours)`));
1021
+ } else {
1022
+ // Interactive resolution
1023
+ // Read base text for context display
1024
+ let baseText = '';
1025
+ try {
1026
+ const { extractFromWord } = await import('../import.js');
1027
+ const { text } = await extractFromWord(data.base);
1028
+ baseText = text;
1029
+ } catch {
1030
+ // Can't read base, show without context
1031
+ }
1032
+
1033
+ for (let i = 0; i < unresolved.length; i++) {
1034
+ const conflict = unresolved[i];
1035
+ console.log(chalk.bold(`\nConflict ${i + 1}/${unresolved.length} (${conflict.id}):`));
1036
+ console.log(formatConflict(conflict, baseText));
1037
+ console.log();
1038
+
1039
+ const rl = await import('readline');
1040
+ const readline = rl.createInterface({
1041
+ input: process.stdin,
1042
+ output: process.stdout,
1043
+ });
1044
+
1045
+ const answer = await new Promise((resolve) =>
1046
+ readline.question(chalk.cyan(` Choose (1-${conflict.changes.length}, s=skip, q=quit): `), resolve)
1047
+ );
1048
+ readline.close();
1049
+
1050
+ if (answer.toLowerCase() === 'q') {
1051
+ console.log(chalk.dim('\n Saving progress...'));
1052
+ break;
1053
+ }
1054
+
1055
+ if (answer.toLowerCase() !== 's' && !isNaN(parseInt(answer))) {
1056
+ const choice = parseInt(answer) - 1;
1057
+ if (choice >= 0 && choice < conflict.changes.length) {
1058
+ resolveConflict(conflict, choice);
1059
+ console.log(chalk.green(` ✓ Applied: ${conflict.changes[choice].reviewer}'s change`));
1060
+ }
1061
+ } else {
1062
+ console.log(chalk.dim(' Skipped'));
1063
+ }
1064
+ }
1065
+
1066
+ saveConflicts(projectDir, data.conflicts, data.base);
1067
+
1068
+ const remaining = data.conflicts.filter(c => c.resolved === null).length;
1069
+ if (remaining === 0) {
1070
+ console.log(fmt.status('success', '\nAll conflicts resolved!'));
1071
+ clearConflicts(projectDir);
1072
+ } else {
1073
+ console.log(chalk.yellow(`\n ${remaining} conflict(s) remaining`));
1074
+ }
1075
+ }
1076
+ });
1077
+
862
1078
  }
package/lib/crossref.js CHANGED
@@ -11,6 +11,49 @@ import * as fs from 'fs';
11
11
  import * as path from 'path';
12
12
  import YAML from 'yaml';
13
13
 
14
+ /**
15
+ * Discover section files from a directory by reading config files
16
+ * Only returns files explicitly defined in rev.yaml or sections.yaml
17
+ * Returns empty array if no config found (caller should handle this)
18
+ *
19
+ * @param {string} directory
20
+ * @returns {string[]} Ordered list of section filenames, or empty if no config
21
+ */
22
+ function discoverSectionFiles(directory) {
23
+ // Try rev.yaml first
24
+ const revYamlPath = path.join(directory, 'rev.yaml');
25
+ if (fs.existsSync(revYamlPath)) {
26
+ try {
27
+ const config = YAML.parse(fs.readFileSync(revYamlPath, 'utf-8'));
28
+ if (config.sections && Array.isArray(config.sections) && config.sections.length > 0) {
29
+ return config.sections.filter(f => fs.existsSync(path.join(directory, f)));
30
+ }
31
+ } catch {
32
+ // Ignore yaml errors, try next option
33
+ }
34
+ }
35
+
36
+ // Try sections.yaml
37
+ const sectionsPath = path.join(directory, 'sections.yaml');
38
+ if (fs.existsSync(sectionsPath)) {
39
+ try {
40
+ const config = YAML.parse(fs.readFileSync(sectionsPath, 'utf-8'));
41
+ if (config.sections) {
42
+ const sectionOrder = Object.entries(config.sections)
43
+ .sort((a, b) => (a[1].order ?? 999) - (b[1].order ?? 999))
44
+ .map(([file]) => file);
45
+ return sectionOrder.filter(f => fs.existsSync(path.join(directory, f)));
46
+ }
47
+ } catch {
48
+ // Ignore yaml errors
49
+ }
50
+ }
51
+
52
+ // No config found - return empty array
53
+ // Caller must handle this (either error or use explicit sections)
54
+ return [];
55
+ }
56
+
14
57
  /**
15
58
  * Patterns for detecting hardcoded references
16
59
  * Matches complex patterns including:
@@ -208,8 +251,14 @@ export function parseReferenceList(listStr) {
208
251
  * Build a registry of figure/table labels from .md files
209
252
  * Scans for {#fig:label} and {#tbl:label} anchors
210
253
  *
254
+ * IMPORTANT: This function requires either explicit sections or a rev.yaml/sections.yaml config.
255
+ * It will NOT guess by scanning all .md files, as this leads to incorrect numbering
256
+ * when temporary files (paper_clean.md, etc.) exist in the directory.
257
+ *
211
258
  * @param {string} directory - Directory containing .md files
212
- * @param {string[]} [excludeFiles] - Files to exclude
259
+ * @param {string[]} [sections] - Array of section filenames to scan (recommended).
260
+ * If not provided, reads from rev.yaml or sections.yaml.
261
+ * Returns empty registry if no sections can be determined.
213
262
  * @returns {{
214
263
  * figures: Map<string, {label: string, num: number, isSupp: boolean, file: string}>,
215
264
  * tables: Map<string, {label: string, num: number, isSupp: boolean, file: string}>,
@@ -217,7 +266,7 @@ export function parseReferenceList(listStr) {
217
266
  * byNumber: {fig: Map<string, string>, tbl: Map<string, string>, eq: Map<string, string>}
218
267
  * }}
219
268
  */
220
- export function buildRegistry(directory, excludeFiles = ['paper.md', 'README.md', 'CLAUDE.md']) {
269
+ export function buildRegistry(directory, sections) {
221
270
  const figures = new Map();
222
271
  const tables = new Map();
223
272
  const equations = new Map();
@@ -229,32 +278,16 @@ export function buildRegistry(directory, excludeFiles = ['paper.md', 'README.md'
229
278
  let tblSuppNum = 0;
230
279
  let eqNum = 0;
231
280
 
232
- // Get all .md files
233
- const files = fs.readdirSync(directory).filter((f) => {
234
- if (!f.endsWith('.md')) return false;
235
- if (excludeFiles.some((e) => f.toLowerCase() === e.toLowerCase())) return false;
236
- return true;
237
- });
281
+ let orderedFiles;
238
282
 
239
- // Sort by likely document order (use sections.yaml if available)
240
- let orderedFiles = files;
241
- const sectionsPath = path.join(directory, 'sections.yaml');
242
- if (fs.existsSync(sectionsPath)) {
243
- try {
244
- const config = YAML.parse(fs.readFileSync(sectionsPath, 'utf-8'));
245
- if (config.sections) {
246
- const sectionOrder = Object.entries(config.sections)
247
- .sort((a, b) => (a[1].order ?? 999) - (b[1].order ?? 999))
248
- .map(([file]) => file);
249
- orderedFiles = sectionOrder.filter((f) => files.includes(f));
250
- // Add any remaining files not in sections.yaml
251
- for (const f of files) {
252
- if (!orderedFiles.includes(f)) orderedFiles.push(f);
253
- }
254
- }
255
- } catch {
256
- // Ignore yaml errors, use default order
257
- }
283
+ if (Array.isArray(sections) && sections.length > 0) {
284
+ // Use explicitly provided section files - most reliable
285
+ orderedFiles = sections.filter(f => fs.existsSync(path.join(directory, f)));
286
+ } else {
287
+ // Try to determine sections from config files (rev.yaml or sections.yaml)
288
+ orderedFiles = discoverSectionFiles(directory);
289
+ // If no config found, return empty registry rather than guessing
290
+ // This prevents bugs from scanning wrong files
258
291
  }
259
292
 
260
293
  // Determine if a file is supplementary
package/lib/merge.js CHANGED
@@ -1,13 +1,21 @@
1
1
  /**
2
2
  * Multi-reviewer merge utilities
3
3
  * Combine feedback from multiple Word documents with conflict detection
4
+ *
5
+ * Supports true three-way merge: base document + multiple reviewer versions
4
6
  */
5
7
 
6
8
  import * as fs from 'fs';
7
9
  import * as path from 'path';
8
- import { diffWords } from 'diff';
10
+ import * as crypto from 'crypto';
11
+ import { diffWords, diffSentences } from 'diff';
9
12
  import { extractFromWord, extractWordComments } from './import.js';
10
13
 
14
+ // Single base document for three-way merge
15
+ const REV_DIR = '.rev';
16
+ const BASE_FILE = '.rev/base.docx';
17
+ const CONFLICTS_FILE = '.rev/conflicts.json';
18
+
11
19
  /**
12
20
  * Represents a change from a reviewer
13
21
  * @typedef {Object} ReviewerChange
@@ -17,27 +25,111 @@ import { extractFromWord, extractWordComments } from './import.js';
17
25
  * @property {number} end - End position in original text
18
26
  * @property {string} oldText - Original text (for delete/replace)
19
27
  * @property {string} newText - New text (for insert/replace)
28
+ * @property {string} [date] - Date of change (from Word track changes)
20
29
  */
21
30
 
22
31
  /**
23
32
  * Represents a conflict between reviewers
24
33
  * @typedef {Object} Conflict
34
+ * @property {string} id - Unique conflict ID
25
35
  * @property {number} start - Start position in original
26
36
  * @property {number} end - End position in original
27
37
  * @property {string} original - Original text
28
38
  * @property {ReviewerChange[]} changes - Conflicting changes from different reviewers
39
+ * @property {string} [section] - Section file this conflict belongs to
40
+ * @property {number} [line] - Line number in section
41
+ * @property {string|null} resolved - Resolution choice or null if unresolved
42
+ */
43
+
44
+ /**
45
+ * Initialize .rev directory
46
+ * @param {string} projectDir
47
+ */
48
+ export function initRevDir(projectDir) {
49
+ const revDir = path.join(projectDir, REV_DIR);
50
+ if (!fs.existsSync(revDir)) {
51
+ fs.mkdirSync(revDir, { recursive: true });
52
+ }
53
+ }
54
+
55
+ /**
56
+ * Store the base document for three-way merge
57
+ * Overwrites any previous base document
58
+ * @param {string} projectDir
59
+ * @param {string} docxPath - Path to the built docx
29
60
  */
61
+ export function storeBaseDocument(projectDir, docxPath) {
62
+ initRevDir(projectDir);
63
+ const basePath = path.join(projectDir, BASE_FILE);
64
+ fs.copyFileSync(docxPath, basePath);
65
+ }
66
+
67
+ /**
68
+ * Get the base document path if it exists
69
+ * @param {string} projectDir
70
+ * @returns {string|null}
71
+ */
72
+ export function getBaseDocument(projectDir) {
73
+ const basePath = path.join(projectDir, BASE_FILE);
74
+ if (fs.existsSync(basePath)) {
75
+ return basePath;
76
+ }
77
+ return null;
78
+ }
79
+
80
+ /**
81
+ * Check if base document exists
82
+ * @param {string} projectDir
83
+ * @returns {boolean}
84
+ */
85
+ export function hasBaseDocument(projectDir) {
86
+ return fs.existsSync(path.join(projectDir, BASE_FILE));
87
+ }
88
+
89
+ /**
90
+ * Compute text similarity between two strings
91
+ * @param {string} text1
92
+ * @param {string} text2
93
+ * @returns {number} Similarity score 0-1
94
+ */
95
+ export function computeSimilarity(text1, text2) {
96
+ const words1 = new Set(text1.toLowerCase().split(/\s+/).filter(w => w.length > 2));
97
+ const words2 = text2.toLowerCase().split(/\s+/).filter(w => w.length > 2);
98
+ if (words1.size === 0 || words2.length === 0) return 0;
99
+ const common = words2.filter(w => words1.has(w)).length;
100
+ return common / Math.max(words1.size, words2.length);
101
+ }
102
+
103
+ /**
104
+ * Check if base document matches reviewer document (similarity check)
105
+ * @param {string} basePath
106
+ * @param {string} reviewerPath
107
+ * @returns {Promise<{matches: boolean, similarity: number}>}
108
+ */
109
+ export async function checkBaseMatch(basePath, reviewerPath) {
110
+ try {
111
+ const { text: baseText } = await extractFromWord(basePath);
112
+ const { text: reviewerText } = await extractFromWord(reviewerPath);
113
+ const similarity = computeSimilarity(baseText, reviewerText);
114
+ return { matches: similarity > 0.5, similarity };
115
+ } catch {
116
+ return { matches: false, similarity: 0 };
117
+ }
118
+ }
30
119
 
31
120
  /**
32
121
  * Extract changes from a Word document compared to original
33
- * @param {string} originalText - Original markdown text
34
- * @param {string} wordText - Text extracted from Word
122
+ * Uses sentence-level diffing for better conflict detection
123
+ * @param {string} originalText - Original text (from base document)
124
+ * @param {string} wordText - Text extracted from reviewer's Word doc
35
125
  * @param {string} reviewer - Reviewer identifier
36
126
  * @returns {ReviewerChange[]}
37
127
  */
38
128
  export function extractChanges(originalText, wordText, reviewer) {
39
129
  const changes = [];
40
- const diffs = diffWords(originalText, wordText);
130
+
131
+ // Use sentence-level diff for better granularity
132
+ const diffs = diffSentences(originalText, wordText);
41
133
 
42
134
  let originalPos = 0;
43
135
  let i = 0;
@@ -90,6 +182,64 @@ export function extractChanges(originalText, wordText, reviewer) {
90
182
  return changes;
91
183
  }
92
184
 
185
+ /**
186
+ * Extract changes using word-level diff (more fine-grained)
187
+ * @param {string} originalText
188
+ * @param {string} wordText
189
+ * @param {string} reviewer
190
+ * @returns {ReviewerChange[]}
191
+ */
192
+ export function extractChangesWordLevel(originalText, wordText, reviewer) {
193
+ const changes = [];
194
+ const diffs = diffWords(originalText, wordText);
195
+
196
+ let originalPos = 0;
197
+ let i = 0;
198
+
199
+ while (i < diffs.length) {
200
+ const part = diffs[i];
201
+
202
+ if (!part.added && !part.removed) {
203
+ originalPos += part.value.length;
204
+ i++;
205
+ } else if (part.removed && diffs[i + 1]?.added) {
206
+ changes.push({
207
+ reviewer,
208
+ type: 'replace',
209
+ start: originalPos,
210
+ end: originalPos + part.value.length,
211
+ oldText: part.value,
212
+ newText: diffs[i + 1].value,
213
+ });
214
+ originalPos += part.value.length;
215
+ i += 2;
216
+ } else if (part.removed) {
217
+ changes.push({
218
+ reviewer,
219
+ type: 'delete',
220
+ start: originalPos,
221
+ end: originalPos + part.value.length,
222
+ oldText: part.value,
223
+ newText: '',
224
+ });
225
+ originalPos += part.value.length;
226
+ i++;
227
+ } else if (part.added) {
228
+ changes.push({
229
+ reviewer,
230
+ type: 'insert',
231
+ start: originalPos,
232
+ end: originalPos,
233
+ oldText: '',
234
+ newText: part.value,
235
+ });
236
+ i++;
237
+ }
238
+ }
239
+
240
+ return changes;
241
+ }
242
+
93
243
  /**
94
244
  * Check if two changes overlap
95
245
  * @param {ReviewerChange} a
@@ -134,6 +284,7 @@ export function detectConflicts(allChanges) {
134
284
  const conflicts = [];
135
285
  const nonConflicting = [];
136
286
  const usedIndices = new Set();
287
+ let conflictId = 0;
137
288
 
138
289
  for (let i = 0; i < flat.length; i++) {
139
290
  if (usedIndices.has(i)) continue;
@@ -162,10 +313,12 @@ export function detectConflicts(allChanges) {
162
313
  const end = Math.max(...conflictingChanges.map(c => c.end));
163
314
 
164
315
  conflicts.push({
316
+ id: `c${++conflictId}`,
165
317
  start,
166
318
  end,
167
319
  original: conflictingChanges[0].oldText || '',
168
320
  changes: conflictingChanges,
321
+ resolved: null,
169
322
  });
170
323
  usedIndices.add(i);
171
324
  } else {
@@ -227,8 +380,6 @@ export function applyChangesAsAnnotations(originalText, changes) {
227
380
  let result = originalText;
228
381
 
229
382
  for (const change of sorted) {
230
- const reviewer = change.reviewer;
231
-
232
383
  if (change.type === 'insert') {
233
384
  const annotation = `{++${change.newText}++}`;
234
385
  result = result.slice(0, change.start) + annotation + result.slice(change.start);
@@ -244,6 +395,42 @@ export function applyChangesAsAnnotations(originalText, changes) {
244
395
  return result;
245
396
  }
246
397
 
398
+ /**
399
+ * Apply changes as git-style conflict markers
400
+ * @param {string} originalText
401
+ * @param {Conflict[]} conflicts
402
+ * @returns {string}
403
+ */
404
+ export function applyConflictMarkers(originalText, conflicts) {
405
+ // Sort by position descending
406
+ const sorted = [...conflicts].sort((a, b) => b.start - a.start);
407
+
408
+ let result = originalText;
409
+
410
+ for (const conflict of sorted) {
411
+ const markers = [];
412
+ markers.push(`<<<<<<< CONFLICT ${conflict.id}`);
413
+
414
+ for (const change of conflict.changes) {
415
+ markers.push(`======= ${change.reviewer}`);
416
+ if (change.type === 'delete') {
417
+ markers.push(`[DELETED: "${change.oldText}"]`);
418
+ } else if (change.type === 'insert') {
419
+ markers.push(change.newText);
420
+ } else {
421
+ markers.push(change.newText);
422
+ }
423
+ }
424
+
425
+ markers.push(`>>>>>>> END ${conflict.id}`);
426
+
427
+ const markerText = markers.join('\n');
428
+ result = result.slice(0, conflict.start) + markerText + result.slice(conflict.end);
429
+ }
430
+
431
+ return result;
432
+ }
433
+
247
434
  /**
248
435
  * Format a conflict for display
249
436
  * @param {Conflict} conflict
@@ -252,35 +439,154 @@ export function applyChangesAsAnnotations(originalText, changes) {
252
439
  */
253
440
  export function formatConflict(conflict, originalText) {
254
441
  const lines = [];
255
- const context = 30;
442
+ const context = 50;
256
443
 
257
444
  // Show context
258
445
  const beforeStart = Math.max(0, conflict.start - context);
259
446
  const afterEnd = Math.min(originalText.length, conflict.end + context);
260
447
 
261
- const before = originalText.slice(beforeStart, conflict.start);
448
+ const before = originalText.slice(beforeStart, conflict.start).trim();
262
449
  const original = originalText.slice(conflict.start, conflict.end);
263
- const after = originalText.slice(conflict.end, afterEnd);
450
+ const after = originalText.slice(conflict.end, afterEnd).trim();
264
451
 
265
- lines.push(`Context: ...${before}[CONFLICT]${after}...`);
266
- lines.push(`Original: "${original || '(insertion point)'}"`);
452
+ if (before) {
453
+ lines.push(` ...${before}`);
454
+ }
455
+ lines.push(` [ORIGINAL]: "${original || '(insertion point)'}"`);
456
+ if (after) {
457
+ lines.push(` ${after}...`);
458
+ }
267
459
  lines.push('');
268
- lines.push('Options:');
460
+ lines.push(' Options:');
269
461
 
270
462
  conflict.changes.forEach((change, i) => {
271
463
  const label = change.type === 'insert'
272
- ? `Insert: "${change.newText}"`
464
+ ? `Insert: "${change.newText.slice(0, 60)}${change.newText.length > 60 ? '...' : ''}"`
273
465
  : change.type === 'delete'
274
- ? `Delete: "${change.oldText}"`
275
- : `Replace "${change.oldText}" → "${change.newText}"`;
276
- lines.push(` ${i + 1}. [${change.reviewer}] ${label}`);
466
+ ? `Delete: "${change.oldText.slice(0, 60)}${change.oldText.length > 60 ? '...' : ''}"`
467
+ : `Replace "${change.newText.slice(0, 60)}${change.newText.length > 60 ? '...' : ''}"`;
468
+ lines.push(` ${i + 1}. [${change.reviewer}] ${label}`);
277
469
  });
278
470
 
279
471
  return lines.join('\n');
280
472
  }
281
473
 
282
474
  /**
283
- * Merge multiple Word documents against an original
475
+ * Save conflicts to file for later resolution
476
+ * @param {string} projectDir
477
+ * @param {Conflict[]} conflicts
478
+ * @param {string} baseDoc - Base document path
479
+ */
480
+ export function saveConflicts(projectDir, conflicts, baseDoc) {
481
+ const conflictsPath = path.join(projectDir, CONFLICTS_FILE);
482
+ const data = {
483
+ base: baseDoc,
484
+ merged: new Date().toISOString(),
485
+ conflicts,
486
+ };
487
+
488
+ // Ensure directory exists
489
+ const dir = path.dirname(conflictsPath);
490
+ if (!fs.existsSync(dir)) {
491
+ fs.mkdirSync(dir, { recursive: true });
492
+ }
493
+
494
+ fs.writeFileSync(conflictsPath, JSON.stringify(data, null, 2));
495
+ }
496
+
497
+ /**
498
+ * Load conflicts from file
499
+ * @param {string} projectDir
500
+ * @returns {{base: string, merged: string, conflicts: Conflict[]}|null}
501
+ */
502
+ export function loadConflicts(projectDir) {
503
+ const conflictsPath = path.join(projectDir, CONFLICTS_FILE);
504
+ if (!fs.existsSync(conflictsPath)) {
505
+ return null;
506
+ }
507
+ return JSON.parse(fs.readFileSync(conflictsPath, 'utf-8'));
508
+ }
509
+
510
+ /**
511
+ * Clear conflicts file after resolution
512
+ * @param {string} projectDir
513
+ */
514
+ export function clearConflicts(projectDir) {
515
+ const conflictsPath = path.join(projectDir, CONFLICTS_FILE);
516
+ if (fs.existsSync(conflictsPath)) {
517
+ fs.unlinkSync(conflictsPath);
518
+ }
519
+ }
520
+
521
+ /**
522
+ * Merge multiple Word documents using three-way merge
523
+ * @param {string} basePath - Path to base document (original sent to reviewers)
524
+ * @param {Array<{path: string, name: string}>} reviewerDocs - Reviewer Word docs
525
+ * @param {Object} options
526
+ * @returns {Promise<{merged: string, conflicts: Conflict[], stats: Object, baseText: string}>}
527
+ */
528
+ export async function mergeThreeWay(basePath, reviewerDocs, options = {}) {
529
+ const { diffLevel = 'sentence' } = options;
530
+
531
+ if (!fs.existsSync(basePath)) {
532
+ throw new Error(`Base document not found: ${basePath}`);
533
+ }
534
+
535
+ // Extract text from base document
536
+ const { text: baseText } = await extractFromWord(basePath);
537
+
538
+ // Extract changes from each reviewer relative to base
539
+ const allChanges = [];
540
+ const allComments = [];
541
+
542
+ for (const doc of reviewerDocs) {
543
+ if (!fs.existsSync(doc.path)) {
544
+ throw new Error(`Reviewer file not found: ${doc.path}`);
545
+ }
546
+
547
+ const { text: wordText } = await extractFromWord(doc.path);
548
+
549
+ // Choose diff level
550
+ const changes = diffLevel === 'word'
551
+ ? extractChangesWordLevel(baseText, wordText, doc.name)
552
+ : extractChanges(baseText, wordText, doc.name);
553
+
554
+ allChanges.push(changes);
555
+
556
+ // Also extract comments
557
+ try {
558
+ const comments = await extractWordComments(doc.path);
559
+ allComments.push(...comments.map(c => ({ ...c, reviewer: doc.name })));
560
+ } catch {
561
+ // Comments extraction failed, continue without
562
+ }
563
+ }
564
+
565
+ // Detect conflicts
566
+ const { conflicts, nonConflicting } = detectConflicts(allChanges);
567
+
568
+ // Apply non-conflicting changes as annotations
569
+ let merged = applyChangesAsAnnotations(baseText, nonConflicting);
570
+
571
+ // Add comments with reviewer attribution
572
+ for (const comment of allComments) {
573
+ merged += `\n{>>${comment.reviewer}: ${comment.text}<<}`;
574
+ }
575
+
576
+ const stats = {
577
+ reviewers: reviewerDocs.length,
578
+ totalChanges: allChanges.flat().length,
579
+ nonConflicting: nonConflicting.length,
580
+ conflicts: conflicts.length,
581
+ comments: allComments.length,
582
+ };
583
+
584
+ return { merged, conflicts, stats, baseText };
585
+ }
586
+
587
+ /**
588
+ * Merge multiple Word documents against an original markdown file
589
+ * Legacy function - use mergeThreeWay for proper three-way merge
284
590
  * @param {string} originalPath - Path to original markdown
285
591
  * @param {Array<{path: string, name: string}>} reviewerDocs - Reviewer Word docs
286
592
  * @param {Object} options
@@ -342,24 +648,26 @@ export async function mergeReviewerDocs(originalPath, reviewerDocs, options = {}
342
648
 
343
649
  /**
344
650
  * Resolve a conflict by choosing one option
345
- * @param {string} text - Current merged text
346
651
  * @param {Conflict} conflict
347
652
  * @param {number} choice - Index of chosen change (0-based)
348
- * @param {string} originalText - Original text for position reference
349
- * @returns {string}
653
+ * @returns {ReviewerChange}
350
654
  */
351
- export function resolveConflict(text, conflict, choice, originalText) {
352
- const chosen = conflict.changes[choice];
353
-
354
- // Find the conflict region in the current text
355
- // This is simplified - real implementation would track positions
356
- const annotation = chosen.type === 'insert'
357
- ? `{++${chosen.newText}++}`
358
- : chosen.type === 'delete'
359
- ? `{--${chosen.oldText}--}`
360
- : `{~~${chosen.oldText}~>${chosen.newText}~~}`;
361
-
362
- // For now, append resolved conflicts at marker position
363
- // A more sophisticated approach would track exact positions
364
- return text + `\n<!-- Resolved: ${annotation} -->`;
655
+ export function resolveConflict(conflict, choice) {
656
+ if (choice < 0 || choice >= conflict.changes.length) {
657
+ throw new Error(`Invalid choice: ${choice}. Must be 0-${conflict.changes.length - 1}`);
658
+ }
659
+ conflict.resolved = conflict.changes[choice].reviewer;
660
+ return conflict.changes[choice];
365
661
  }
662
+
663
+ /**
664
+ * Get list of unresolved conflicts
665
+ * @param {string} projectDir
666
+ * @returns {Conflict[]}
667
+ */
668
+ export function getUnresolvedConflicts(projectDir) {
669
+ const data = loadConflicts(projectDir);
670
+ if (!data) return [];
671
+ return data.conflicts.filter(c => c.resolved === null);
672
+ }
673
+
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "docrev",
3
- "version": "0.7.8",
3
+ "version": "0.8.0",
4
4
  "description": "Academic paper revision workflow: Word ↔ Markdown round-trips, DOI validation, reviewer comments",
5
5
  "type": "module",
6
6
  "types": "types/index.d.ts",