docrev 0.10.2 → 0.11.1

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 (86) hide show
  1. package/CHANGELOG.md +13 -0
  2. package/README.md +2 -0
  3. package/dist/lib/anchor-match.d.ts +3 -1
  4. package/dist/lib/anchor-match.d.ts.map +1 -1
  5. package/dist/lib/anchor-match.js +5 -7
  6. package/dist/lib/anchor-match.js.map +1 -1
  7. package/dist/lib/annotations.d.ts +0 -6
  8. package/dist/lib/annotations.d.ts.map +1 -1
  9. package/dist/lib/annotations.js +26 -8
  10. package/dist/lib/annotations.js.map +1 -1
  11. package/dist/lib/build.d.ts +52 -0
  12. package/dist/lib/build.d.ts.map +1 -1
  13. package/dist/lib/build.js +135 -6
  14. package/dist/lib/build.js.map +1 -1
  15. package/dist/lib/commands/build.d.ts.map +1 -1
  16. package/dist/lib/commands/build.js +39 -12
  17. package/dist/lib/commands/build.js.map +1 -1
  18. package/dist/lib/commands/doi.d.ts.map +1 -1
  19. package/dist/lib/commands/doi.js +14 -2
  20. package/dist/lib/commands/doi.js.map +1 -1
  21. package/dist/lib/commands/sync.d.ts.map +1 -1
  22. package/dist/lib/commands/sync.js +24 -11
  23. package/dist/lib/commands/sync.js.map +1 -1
  24. package/dist/lib/comment-realign.d.ts +12 -21
  25. package/dist/lib/comment-realign.d.ts.map +1 -1
  26. package/dist/lib/comment-realign.js +40 -348
  27. package/dist/lib/comment-realign.js.map +1 -1
  28. package/dist/lib/dependencies.d.ts +10 -0
  29. package/dist/lib/dependencies.d.ts.map +1 -1
  30. package/dist/lib/dependencies.js +41 -9
  31. package/dist/lib/dependencies.js.map +1 -1
  32. package/dist/lib/doi.d.ts.map +1 -1
  33. package/dist/lib/doi.js +68 -80
  34. package/dist/lib/doi.js.map +1 -1
  35. package/dist/lib/errors.d.ts.map +1 -1
  36. package/dist/lib/errors.js +3 -1
  37. package/dist/lib/errors.js.map +1 -1
  38. package/dist/lib/import.d.ts +7 -4
  39. package/dist/lib/import.d.ts.map +1 -1
  40. package/dist/lib/import.js +58 -62
  41. package/dist/lib/import.js.map +1 -1
  42. package/dist/lib/ooxml.d.ts +207 -0
  43. package/dist/lib/ooxml.d.ts.map +1 -0
  44. package/dist/lib/ooxml.js +634 -0
  45. package/dist/lib/ooxml.js.map +1 -0
  46. package/dist/lib/rate-limiter.d.ts +8 -0
  47. package/dist/lib/rate-limiter.d.ts.map +1 -1
  48. package/dist/lib/rate-limiter.js +41 -4
  49. package/dist/lib/rate-limiter.js.map +1 -1
  50. package/dist/lib/trackchanges.d.ts +41 -25
  51. package/dist/lib/trackchanges.d.ts.map +1 -1
  52. package/dist/lib/trackchanges.js +90 -151
  53. package/dist/lib/trackchanges.js.map +1 -1
  54. package/dist/lib/types.d.ts +9 -8
  55. package/dist/lib/types.d.ts.map +1 -1
  56. package/dist/lib/word-extraction.d.ts.map +1 -1
  57. package/dist/lib/word-extraction.js +48 -307
  58. package/dist/lib/word-extraction.js.map +1 -1
  59. package/dist/lib/word.d.ts.map +1 -1
  60. package/dist/lib/word.js +80 -175
  61. package/dist/lib/word.js.map +1 -1
  62. package/dist/lib/wordcomments.d.ts.map +1 -1
  63. package/dist/lib/wordcomments.js +53 -92
  64. package/dist/lib/wordcomments.js.map +1 -1
  65. package/lib/anchor-match.ts +6 -7
  66. package/lib/annotations.ts +28 -8
  67. package/lib/build.ts +196 -6
  68. package/lib/commands/build.ts +42 -12
  69. package/lib/commands/doi.ts +17 -1
  70. package/lib/commands/sync.ts +26 -13
  71. package/lib/comment-realign.ts +46 -464
  72. package/lib/dependencies.ts +43 -9
  73. package/lib/doi.ts +76 -94
  74. package/lib/errors.ts +5 -1
  75. package/lib/import.ts +65 -70
  76. package/lib/ooxml.ts +768 -0
  77. package/lib/rate-limiter.ts +37 -4
  78. package/lib/trackchanges.ts +102 -183
  79. package/lib/types.ts +9 -13
  80. package/lib/word-extraction.ts +49 -330
  81. package/lib/word.ts +86 -203
  82. package/lib/wordcomments.ts +53 -104
  83. package/package.json +137 -137
  84. package/skill/REFERENCE.md +14 -3
  85. package/skill/SKILL.md +12 -5
  86. package/types/index.d.ts +16 -10
@@ -2,39 +2,68 @@
2
2
  * Dependency checking utilities for pandoc, LaTeX, and related tools
3
3
  */
4
4
 
5
- import { execSync } from 'child_process';
5
+ import { execFileSync } from 'child_process';
6
6
 
7
7
  /**
8
- * Check if a command is available by running it silently
8
+ * Run `<file> --version` without a shell and return its stdout, or null when
9
+ * the binary is absent (ENOENT) or exits non-zero (present but broken). Using
10
+ * execFileSync avoids shell quoting and treats both failure modes uniformly.
9
11
  */
10
- function commandExists(cmd: string): boolean {
12
+ function runVersion(file: string, args: string[] = ['--version']): string | null {
11
13
  try {
12
- execSync(cmd, { stdio: 'ignore' });
13
- return true;
14
+ return execFileSync(file, args, { stdio: ['ignore', 'pipe', 'ignore'], encoding: 'utf-8' });
14
15
  } catch {
15
- return false;
16
+ return null;
16
17
  }
17
18
  }
18
19
 
20
+ function commandExists(file: string, args: string[] = ['--version']): boolean {
21
+ return runVersion(file, args) !== null;
22
+ }
23
+
19
24
  /**
20
25
  * Check if pandoc-crossref is available
21
26
  */
22
27
  export function hasPandocCrossref(): boolean {
23
- return commandExists('pandoc-crossref --version');
28
+ return commandExists('pandoc-crossref');
24
29
  }
25
30
 
26
31
  /**
27
32
  * Check if pandoc is available
28
33
  */
29
34
  export function hasPandoc(): boolean {
30
- return commandExists('pandoc --version');
35
+ return commandExists('pandoc');
36
+ }
37
+
38
+ /**
39
+ * Parsed pandoc version (e.g. "3.9"), or null when pandoc is unavailable.
40
+ */
41
+ export function getPandocVersion(): string | null {
42
+ const out = runVersion('pandoc');
43
+ if (!out) return null;
44
+ const m = out.match(/pandoc(?:\.exe)?\s+(\d+\.\d+(?:\.\d+)?)/i);
45
+ return m ? m[1]! : null;
46
+ }
47
+
48
+ /**
49
+ * Whether pandoc bundles citeproc and accepts `--citeproc`. That flag and the
50
+ * built-in citeproc arrived in pandoc 2.11; earlier versions need the separate
51
+ * pandoc-citeproc filter.
52
+ */
53
+ export function pandocSupportsCiteproc(): boolean {
54
+ const v = getPandocVersion();
55
+ if (!v) return false;
56
+ const parts = v.split('.').map((n) => parseInt(n, 10));
57
+ const major = parts[0] ?? 0;
58
+ const minor = parts[1] ?? 0;
59
+ return major > 2 || (major === 2 && minor >= 11);
31
60
  }
32
61
 
33
62
  /**
34
63
  * Check if LaTeX is available (for PDF generation)
35
64
  */
36
65
  export function hasLatex(): boolean {
37
- return commandExists('pdflatex --version') || commandExists('xelatex --version');
66
+ return commandExists('pdflatex') || commandExists('xelatex');
38
67
  }
39
68
 
40
69
  /**
@@ -86,6 +115,11 @@ export function checkDependencies(): DependencyStatus {
86
115
 
87
116
  if (!status.pandoc) {
88
117
  status.messages.push(`Pandoc not found. Install with: ${getInstallInstructions('pandoc')}`);
118
+ } else if (!pandocSupportsCiteproc()) {
119
+ const version = getPandocVersion();
120
+ status.messages.push(
121
+ `Pandoc ${version ?? '(unknown version)'} is older than 2.11; citation processing (--citeproc) needs 2.11+. Upgrade with: ${getInstallInstructions('pandoc')}`,
122
+ );
89
123
  }
90
124
  if (!status.latex) {
91
125
  status.messages.push(`LaTeX not found (required for PDF). Install with: ${getInstallInstructions('latex')}`);
package/lib/doi.ts CHANGED
@@ -22,6 +22,16 @@ const NO_DOI_TYPES = new Set([
22
22
  'booklet',
23
23
  ]);
24
24
 
25
+ // Thresholds for the heuristic DOI-match score (title + author + year +
26
+ // journal). `high` auto-accepts, `medium` asks the user, below `medium` is a
27
+ // weak guess. `runnerUpMargin` is how far the best must beat the second-best
28
+ // to count as high — guards against picking the wrong one of two near-ties.
29
+ const DOI_CONFIDENCE = {
30
+ high: 120,
31
+ medium: 70,
32
+ runnerUpMargin: 30,
33
+ };
34
+
25
35
  // Entry types that should have DOIs
26
36
  const EXPECT_DOI_TYPES = new Set([
27
37
  'article', // Journal articles should have DOIs
@@ -142,52 +152,6 @@ export function isValidDoiFormat(doi: string): boolean {
142
152
  return /^10\.\d{4,}\/[^\s]+$/.test(doi);
143
153
  }
144
154
 
145
- /**
146
- * Check if DOI resolves via DataCite (for Zenodo, Figshare, etc.)
147
- */
148
- async function checkDoiDataCite(doi: string): Promise<DoiCheckResult> {
149
- try {
150
- const response = await dataciteLimiter.fetchWithRetry(
151
- `https://api.datacite.org/dois/${encodeURIComponent(doi)}`,
152
- {
153
- headers: {
154
- 'Accept': 'application/vnd.api+json',
155
- 'User-Agent': 'docrev/0.6.0 (https://github.com/gcol33/docrev)',
156
- },
157
- }
158
- );
159
-
160
- if (response.status === 404) {
161
- return { valid: false, error: 'DOI not found in DataCite' };
162
- }
163
-
164
- if (!response.ok) {
165
- return { valid: false, error: `HTTP ${response.status}` };
166
- }
167
-
168
- const data = await response.json() as any;
169
- const attrs = data.data?.attributes;
170
-
171
- if (!attrs) {
172
- return { valid: false, error: 'Invalid DataCite response' };
173
- }
174
-
175
- return {
176
- valid: true,
177
- source: 'datacite',
178
- metadata: {
179
- title: attrs.titles?.[0]?.title || '',
180
- authors: attrs.creators?.map((c: any) => `${c.givenName || ''} ${c.familyName || ''}`.trim()) || [],
181
- year: attrs.publicationYear,
182
- journal: attrs.publisher || '',
183
- type: attrs.types?.resourceTypeGeneral || '',
184
- },
185
- };
186
- } catch (err) {
187
- return { valid: false, error: (err as Error).message };
188
- }
189
- }
190
-
191
155
  interface CheckDoiOptions {
192
156
  skipCache?: boolean;
193
157
  }
@@ -209,72 +173,80 @@ export async function checkDoi(doi: string, options: CheckDoiOptions = {}): Prom
209
173
  }
210
174
  }
211
175
 
212
- // Zenodo DOIs start with 10.5281 - check DataCite first
213
- const isZenodo = doi.startsWith('10.5281/');
214
- const isFigshare = doi.startsWith('10.6084/');
215
- const isDataCiteLikely = isZenodo || isFigshare;
216
-
217
- if (isDataCiteLikely) {
218
- const dataciteResult = await checkDoiDataCite(doi);
219
- if (dataciteResult.valid) {
220
- cacheDoi(doi, dataciteResult);
221
- return dataciteResult;
222
- }
223
- }
224
-
176
+ // One resolution path: doi.org content negotiation returns CSL-JSON and
177
+ // federates every registration agency (Crossref, DataCite, mEDRA, ...), so
178
+ // there is no registrar to guess and no Crossref-then-DataCite fallback.
225
179
  try {
226
- // Use Crossref API to check DOI
227
- const response = await crossrefLimiter.fetchWithRetry(
228
- `https://api.crossref.org/works/${encodeURIComponent(doi)}`,
180
+ const response = await doiOrgLimiter.fetchWithRetry(
181
+ `https://doi.org/${encodeURIComponent(doi)}`,
229
182
  {
230
183
  headers: {
231
- 'User-Agent': 'docrev/0.6.0 (https://github.com/gcol33/docrev; mailto:docrev@example.com)',
184
+ 'Accept': 'application/vnd.citationstyles.csl+json',
185
+ 'User-Agent': 'docrev (https://github.com/gcol33/docrev)',
232
186
  },
233
- }
187
+ redirect: 'follow',
188
+ },
234
189
  );
235
190
 
236
- if (response.status === 404) {
237
- // Try DataCite as fallback (if not already tried)
238
- if (!isDataCiteLikely) {
239
- const dataciteResult = await checkDoiDataCite(doi);
240
- if (dataciteResult.valid) {
241
- cacheDoi(doi, dataciteResult);
242
- return dataciteResult;
243
- }
244
- }
245
- const result = { valid: false, error: 'DOI not found' };
191
+ if (response.status === 404 || response.status === 410) {
192
+ // Definitive: the registry resolved and the DOI is not registered.
193
+ const result: DoiCheckResult = { valid: false, error: 'DOI not found' };
246
194
  cacheDoi(doi, result);
247
195
  return result;
248
196
  }
249
197
 
250
198
  if (!response.ok) {
251
- // Don't cache transient errors
252
- return { valid: false, error: `HTTP ${response.status}` };
199
+ // 5xx or unexpected: the registry is reachable but not answering — the
200
+ // DOI's status is unknown, so do not declare it invalid or cache it.
201
+ return { valid: false, unreachable: true, error: `HTTP ${response.status}` };
253
202
  }
254
203
 
255
- const data = await response.json() as any;
256
- const work = data.message;
257
-
204
+ const work = await response.json() as any;
258
205
  const result: DoiCheckResult = {
259
206
  valid: true,
260
- source: 'crossref',
261
- metadata: {
262
- title: work.title?.[0] || '',
263
- authors: work.author?.map((a: any) => `${a.given || ''} ${a.family || ''}`.trim()) || [],
264
- year: work.published?.['date-parts']?.[0]?.[0] || work.created?.['date-parts']?.[0]?.[0],
265
- journal: work['container-title']?.[0] || '',
266
- type: work.type,
267
- },
207
+ source: 'doi.org',
208
+ metadata: metadataFromCsl(work),
268
209
  };
269
210
 
270
211
  cacheDoi(doi, result);
271
212
  return result;
272
213
  } catch (err) {
273
- // Don't cache network errors
274
- return { valid: false, error: (err as Error).message };
214
+ // Network failure / timeout: cannot reach the resolver. Unknown, not invalid.
215
+ return { valid: false, unreachable: true, error: (err as Error).message };
275
216
  }
276
217
  }
277
218
 
219
+ /**
220
+ * Extract docrev's metadata fields from a CSL-JSON record — the shape doi.org
221
+ * content negotiation returns for any registrar.
222
+ */
223
+ function metadataFromCsl(work: any): DoiCheckResult['metadata'] {
224
+ const dateParts =
225
+ work?.issued?.['date-parts']?.[0] ||
226
+ work?.published?.['date-parts']?.[0] ||
227
+ work?.created?.['date-parts']?.[0];
228
+ const year = Array.isArray(dateParts) ? dateParts[0] : undefined;
229
+
230
+ const authors = Array.isArray(work?.author)
231
+ ? work.author
232
+ .map((a: any) => `${a.given || a.givenName || ''} ${a.family || a.familyName || ''}`.trim())
233
+ .filter((s: string) => s.length > 0)
234
+ : [];
235
+
236
+ const title = Array.isArray(work?.title) ? work.title[0] : work?.title;
237
+ const journal = Array.isArray(work?.['container-title'])
238
+ ? work['container-title'][0]
239
+ : work?.['container-title'] || work?.publisher;
240
+
241
+ return {
242
+ title: title || '',
243
+ authors,
244
+ year: year || 0,
245
+ journal: journal || '',
246
+ type: work?.type,
247
+ };
248
+ }
249
+
278
250
  /**
279
251
  * Fetch BibTeX from DOI using content negotiation
280
252
  */
@@ -330,6 +302,7 @@ export async function checkBibDois(bibPath: string, options: CheckBibDoisOptions
330
302
 
331
303
  let valid = 0;
332
304
  let invalid = 0;
305
+ let unreachable = 0;
333
306
  let missing = 0;
334
307
  let skipped = 0;
335
308
 
@@ -372,6 +345,11 @@ export async function checkBibDois(bibPath: string, options: CheckBibDoisOptions
372
345
  if (check.valid) {
373
346
  valid++;
374
347
  return { ...entry, status: 'valid', metadata: check.metadata };
348
+ } else if (check.unreachable) {
349
+ // Registry unreachable — the DOI may be perfectly valid; don't
350
+ // condemn it. Offline runs report unreachable, not a wall of invalid.
351
+ unreachable++;
352
+ return { ...entry, status: 'unreachable', message: check.error };
375
353
  } else {
376
354
  invalid++;
377
355
  return { ...entry, status: 'invalid', message: check.error };
@@ -387,7 +365,7 @@ export async function checkBibDois(bibPath: string, options: CheckBibDoisOptions
387
365
  }
388
366
  }
389
367
 
390
- return { entries: results, valid, invalid, missing, skipped };
368
+ return { entries: results, valid, invalid, unreachable, missing, skipped };
391
369
  }
392
370
 
393
371
  interface DataCiteItem {
@@ -725,10 +703,14 @@ export async function lookupDoi(
725
703
  return { found: false, error: 'No valid results found' };
726
704
  }
727
705
 
728
- // Confidence thresholds
706
+ // Confidence: a "high" pick must clear the high threshold AND beat the
707
+ // runner-up by a margin, so two near-identical candidates are reported as
708
+ // "medium" (needs review) instead of one being auto-written as if certain.
709
+ const runnerUp = (mainPapers.length > 0 ? mainPapers : scored).find(s => s.doi !== best.doi);
710
+ const margin = runnerUp ? best.score - runnerUp.score : Infinity;
729
711
  let confidence: 'low' | 'medium' | 'high' = 'low';
730
- if (best.score >= 120) confidence = 'high';
731
- else if (best.score >= 70) confidence = 'medium';
712
+ if (best.score >= DOI_CONFIDENCE.high && margin >= DOI_CONFIDENCE.runnerUpMargin) confidence = 'high';
713
+ else if (best.score >= DOI_CONFIDENCE.medium) confidence = 'medium';
732
714
 
733
715
  // === NEW: Try DataCite if Crossref confidence is low ===
734
716
  if (confidence === 'low' && !likelyZenodo) {
@@ -758,7 +740,7 @@ export async function lookupDoi(
758
740
  return {
759
741
  found: true,
760
742
  doi: dcItem.DOI,
761
- confidence: dcScore >= 120 ? 'high' : dcScore >= 70 ? 'medium' : 'low',
743
+ confidence: dcScore >= DOI_CONFIDENCE.high ? 'high' : dcScore >= DOI_CONFIDENCE.medium ? 'medium' : 'low',
762
744
  score: dcScore,
763
745
  metadata: {
764
746
  title: dcTitle,
package/lib/errors.ts CHANGED
@@ -44,7 +44,11 @@ export function getFileNotFoundSuggestions(filePath: string): string[] {
44
44
  // Check if directory exists
45
45
  if (!fs.existsSync(dir)) {
46
46
  suggestions.push(`Directory does not exist: ${dir}`);
47
- suggestions.push(`Create it with: mkdir -p "${dir}"`);
47
+ suggestions.push(
48
+ process.platform === 'win32'
49
+ ? `Create it with: New-Item -ItemType Directory -Force "${dir}"`
50
+ : `Create it with: mkdir -p "${dir}"`,
51
+ );
48
52
  return suggestions;
49
53
  }
50
54
 
package/lib/import.ts CHANGED
@@ -53,6 +53,7 @@ function pickBestOccurrence(
53
53
  after: string,
54
54
  anchorLen: number,
55
55
  usedPositions: Set<number>,
56
+ nearPos?: number,
56
57
  ): number {
57
58
  if (occurrences.length === 0) return -1;
58
59
  if (occurrences.length === 1) {
@@ -63,6 +64,13 @@ function pickBestOccurrence(
63
64
  if (bestIdx < 0) return -1;
64
65
  let bestScore = -1;
65
66
 
67
+ // Tie-break by context score first; when scores tie, prefer the occurrence
68
+ // nearest `nearPos` (a section-position estimate) if given, else the
69
+ // leftmost. The estimate only orders equally-scored candidates — it is never
70
+ // the returned position.
71
+ const closer = (pos: number, incumbent: number) =>
72
+ nearPos === undefined ? pos < incumbent : Math.abs(pos - nearPos) < Math.abs(incumbent - nearPos);
73
+
66
74
  for (const pos of occurrences) {
67
75
  if (usedPositions.has(pos)) continue;
68
76
  let score = 0;
@@ -87,7 +95,7 @@ function pickBestOccurrence(
87
95
  if (contextAfter.includes(afterLower.slice(0, 30))) score += 5;
88
96
  }
89
97
 
90
- if (score > bestScore || (score === bestScore && pos < bestIdx)) {
98
+ if (score > bestScore || (score === bestScore && closer(pos, bestIdx))) {
91
99
  bestScore = score;
92
100
  bestIdx = pos;
93
101
  }
@@ -160,12 +168,24 @@ export interface InsertCommentsOptions {
160
168
  wrapAnchor?: boolean;
161
169
  /**
162
170
  * Mutable output: when provided, the function fills in counters so callers
163
- * can distinguish placement outcomes in their summary. `placed` counts new
164
- * insertions, `deduped` counts comments that were already present at their
165
- * anchor (skipped to avoid duplication on re-sync), `unmatched` counts
166
- * comments whose anchor couldn't be located.
171
+ * can distinguish placement outcomes in their summary. `placed` counts
172
+ * insertions made at a matched anchor or context, `lowConfidence` counts
173
+ * insertions made at an approximate (proportional / context-only) position
174
+ * that should be reviewed with `rev verify-anchors`, `deduped` counts
175
+ * comments already present at their anchor (skipped on re-sync), and
176
+ * `unmatched` counts comments that could not be placed at all.
167
177
  */
168
- outStats?: { placed: number; deduped: number; unmatched: number };
178
+ outStats?: { placed: number; deduped: number; unmatched: number; lowConfidence?: number };
179
+ }
180
+
181
+ /**
182
+ * Whether a resolved placement landed on matched anchor/context text (high) or
183
+ * on an approximate position the user should review (low). Low-confidence
184
+ * strategies place the comment without matching the anchor word itself.
185
+ */
186
+ function placementConfidence(strategy: string | undefined): 'high' | 'low' {
187
+ if (!strategy) return 'high';
188
+ return /proportional|position-only|context/.test(strategy) ? 'low' : 'high';
169
189
  }
170
190
 
171
191
  export interface CommentWithPos {
@@ -319,6 +339,7 @@ export function insertCommentsIntoMarkdown(
319
339
  let result = markdown;
320
340
  let unmatchedCount = 0;
321
341
  let placedCount = 0;
342
+ let lowConfidenceCount = 0;
322
343
  const duplicateWarnings: string[] = [];
323
344
  const usedPositions = new Set<number>(); // For tie-breaking: track used positions
324
345
 
@@ -370,24 +391,19 @@ export function insertCommentsIntoMarkdown(
370
391
  const isEmpty = typeof anchorData === 'object' && anchorData.isEmpty;
371
392
  const docPosition = typeof anchorData === 'object' ? anchorData.docPosition : undefined;
372
393
 
373
- // Position-based insertion (most reliable)
394
+ // Section-scoped insertion. The comment is known to belong to this
395
+ // section (the caller already filtered by docPosition); place it by
396
+ // matching its anchor text within the section, never by mapping a length
397
+ // ratio across the docx and markdown coordinate systems. The section
398
+ // estimate only seeds duplicate tie-breaking and the final fallback.
374
399
  if (sectionBoundary && docPosition !== undefined) {
375
400
  const sectionLength = sectionBoundary.end - sectionBoundary.start;
376
401
  if (sectionLength > 0) {
377
- let relativePos;
378
- if (docPosition < sectionBoundary.start) {
379
- relativePos = 0;
380
- } else {
381
- relativePos = docPosition - sectionBoundary.start;
382
- }
383
-
402
+ const relativePos = docPosition < sectionBoundary.start ? 0 : docPosition - sectionBoundary.start;
384
403
  const proportion = Math.min(relativePos / sectionLength, 1.0);
385
- const markdownPos = Math.floor(proportion * result.length);
404
+ const estimatedPos = Math.floor(proportion * result.length);
386
405
 
387
- // For empty anchors, before/after context is the only signal that
388
- // pinpoints the original split — without it, proportional placement
389
- // can land mid-word or split unrelated phrases. Try context match
390
- // first; only fall through to proportional when context is gone.
406
+ // Empty anchor: before/after context is the only text signal.
391
407
  if ((!anchor || isEmpty) && (before || after)) {
392
408
  const ctx = findAnchorInText('', result, before, after);
393
409
  if (ctx.occurrences.length > 0) {
@@ -396,62 +412,32 @@ export function insertCommentsIntoMarkdown(
396
412
  }
397
413
  }
398
414
 
399
- let insertPos = markdownPos;
400
-
401
- // Look for nearby word boundary
402
- const searchWindow = result.slice(Math.max(0, markdownPos - 25), Math.min(result.length, markdownPos + 25));
403
- const spaceIdx = searchWindow.indexOf(' ', 25);
404
- if (spaceIdx !== -1 && spaceIdx < 50) {
405
- insertPos = Math.max(0, markdownPos - 25) + spaceIdx;
406
- }
407
-
408
- // If we have anchor text, try to find it near this position.
409
- // Collect ALL occurrences in the local window, then disambiguate
410
- // via before/after context + usedPositions — otherwise two
411
- // comments sharing the same anchor word would both collide at
412
- // the leftmost match. The context-scoring helper handles the
413
- // "repeated formulaic prose" case using docx-side context, which
414
- // is a stronger signal than raw distance to the proportional
415
- // insertPos (insertPos is itself an approximation).
415
+ // Non-empty anchor: locate it anywhere in the section text. Duplicates
416
+ // are disambiguated by before/after context and, only as a tie-break,
417
+ // by proximity to the section estimate.
416
418
  if (anchor && !isEmpty) {
417
- const searchStart = Math.max(0, insertPos - 200);
418
- const searchEnd = Math.min(result.length, insertPos + 200);
419
- const localSearch = result.slice(searchStart, searchEnd).toLowerCase();
420
- const anchorLower = anchor.toLowerCase();
421
-
422
- const localHits = findAllOccurrences(localSearch, anchorLower).map(i => searchStart + i);
423
- if (localHits.length > 0) {
424
- const chosen = pickBestOccurrence(localHits, result, before, after, anchor.length, usedPositions);
425
- if (chosen >= 0) {
426
- if (localHits.length > 1) {
427
- duplicateWarnings.push(`"${anchor.slice(0, 40)}${anchor.length > 40 ? '...' : ''}" appears ${localHits.length} times in section window`);
428
- }
429
- usedPositions.add(chosen);
430
- return { ...c, pos: chosen, anchorText: anchor, anchorEnd: chosen + anchor.length, strategy: 'position+text' };
419
+ const { occurrences, matchedAnchor, strategy } = findAnchorInText(anchor, result, before, after);
420
+ if (occurrences.length > 0) {
421
+ const anchorLen = matchedAnchor ? matchedAnchor.length : 0;
422
+ const chosen = pickBestOccurrence(occurrences, result, before, after, anchorLen, usedPositions, estimatedPos);
423
+ const finalIdx = chosen >= 0 ? chosen : occurrences[0];
424
+ if (occurrences.length > 1 && matchedAnchor) {
425
+ duplicateWarnings.push(`"${matchedAnchor.slice(0, 40)}${matchedAnchor.length > 40 ? '...' : ''}" appears ${occurrences.length} times in section`);
431
426
  }
432
- }
433
-
434
- // Try first few words
435
- const words = anchor.split(/\s+/).slice(0, 4).join(' ').toLowerCase();
436
- if (words.length >= 10) {
437
- const partialHits = findAllOccurrences(localSearch, words).map(i => searchStart + i);
438
- if (partialHits.length > 0) {
439
- const chosen = pickBestOccurrence(partialHits, result, before, after, words.length, usedPositions);
440
- if (chosen >= 0) {
441
- usedPositions.add(chosen);
442
- return { ...c, pos: chosen, anchorText: words, anchorEnd: chosen + words.length, strategy: 'position+partial' };
443
- }
427
+ usedPositions.add(finalIdx);
428
+ if (matchedAnchor) {
429
+ return { ...c, pos: finalIdx, anchorText: matchedAnchor, anchorEnd: finalIdx + anchorLen, strategy: `section:${strategy}` };
444
430
  }
431
+ return { ...c, pos: finalIdx, anchorText: null, strategy: `section:${strategy}` };
445
432
  }
446
433
  }
447
434
 
448
- // A docPosition at the very start of a section maps to markdownPos=0,
449
- // which sits before the file's `# Heading` line and gets rendered in
450
- // the previous section. Push past the heading line so the comment
451
- // stays inside the section it was authored in.
452
- insertPos = pushPastSectionHeading(result, insertPos);
453
-
454
- return { ...c, pos: insertPos, anchorText: null, strategy: 'position-only' };
435
+ // Anchor text and context are both gone from the section. The docx
436
+ // marker's offset is the only remaining signal: map it proportionally
437
+ // and snap to a word boundary. This is an approximate placement, not a
438
+ // match flagged low-confidence so the summary surfaces it.
439
+ const insertPos = pushPastSectionHeading(result, snapToWordBoundary(result, estimatedPos));
440
+ return { ...c, pos: insertPos, anchorText: null, strategy: 'proportional-fallback' };
455
441
  }
456
442
  }
457
443
 
@@ -599,11 +585,17 @@ export function insertCommentsIntoMarkdown(
599
585
  } else {
600
586
  result = result.slice(0, c.pos) + combined + result.slice(c.pos);
601
587
  }
602
- placedCount += 1 + replies.length;
588
+ // Replies inherit the root's confidence — they ride the same position.
589
+ if (placementConfidence(c.strategy) === 'low') {
590
+ lowConfidenceCount += 1 + replies.length;
591
+ } else {
592
+ placedCount += 1 + replies.length;
593
+ }
603
594
  }
604
595
 
605
596
  if (outStats) {
606
597
  outStats.placed = placedCount;
598
+ outStats.lowConfidence = lowConfidenceCount;
607
599
  outStats.deduped = dedupedCount;
608
600
  outStats.unmatched = unmatchedCount;
609
601
  }
@@ -613,6 +605,9 @@ export function insertCommentsIntoMarkdown(
613
605
  if (unmatchedCount > 0) {
614
606
  console.warn(`Warning: ${unmatchedCount} comment(s) could not be matched to anchor text`);
615
607
  }
608
+ if (lowConfidenceCount > 0) {
609
+ console.warn(`Note: ${lowConfidenceCount} comment(s) placed approximately — run \`rev verify-anchors\` to review`);
610
+ }
616
611
  if (dedupedCount > 0) {
617
612
  console.warn(`Note: ${dedupedCount} comment(s) already present at anchor — skipped to avoid duplication`);
618
613
  }