docrev 0.10.1 → 0.11.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.
Files changed (167) hide show
  1. package/.gitattributes +1 -1
  2. package/CHANGELOG.md +184 -173
  3. package/PLAN-tables-and-postprocess.md +850 -850
  4. package/README.md +431 -431
  5. package/bin/rev.js +11 -11
  6. package/bin/rev.ts +145 -145
  7. package/completions/rev.bash +127 -127
  8. package/completions/rev.ps1 +210 -210
  9. package/completions/rev.zsh +207 -207
  10. package/dist/lib/anchor-match.d.ts +4 -2
  11. package/dist/lib/anchor-match.d.ts.map +1 -1
  12. package/dist/lib/anchor-match.js +52 -24
  13. package/dist/lib/anchor-match.js.map +1 -1
  14. package/dist/lib/annotations.d.ts +0 -6
  15. package/dist/lib/annotations.d.ts.map +1 -1
  16. package/dist/lib/annotations.js +26 -8
  17. package/dist/lib/annotations.js.map +1 -1
  18. package/dist/lib/build.d.ts.map +1 -1
  19. package/dist/lib/build.js +13 -10
  20. package/dist/lib/build.js.map +1 -1
  21. package/dist/lib/commands/build.d.ts.map +1 -1
  22. package/dist/lib/commands/build.js +5 -0
  23. package/dist/lib/commands/build.js.map +1 -1
  24. package/dist/lib/commands/doi.d.ts.map +1 -1
  25. package/dist/lib/commands/doi.js +14 -2
  26. package/dist/lib/commands/doi.js.map +1 -1
  27. package/dist/lib/commands/sync.d.ts.map +1 -1
  28. package/dist/lib/commands/sync.js +98 -107
  29. package/dist/lib/commands/sync.js.map +1 -1
  30. package/dist/lib/commands/utilities.js +164 -164
  31. package/dist/lib/commands/word-tools.js +8 -8
  32. package/dist/lib/comment-realign.d.ts +12 -21
  33. package/dist/lib/comment-realign.d.ts.map +1 -1
  34. package/dist/lib/comment-realign.js +40 -348
  35. package/dist/lib/comment-realign.js.map +1 -1
  36. package/dist/lib/dependencies.d.ts +10 -0
  37. package/dist/lib/dependencies.d.ts.map +1 -1
  38. package/dist/lib/dependencies.js +41 -9
  39. package/dist/lib/dependencies.js.map +1 -1
  40. package/dist/lib/doi.d.ts.map +1 -1
  41. package/dist/lib/doi.js +68 -80
  42. package/dist/lib/doi.js.map +1 -1
  43. package/dist/lib/errors.d.ts.map +1 -1
  44. package/dist/lib/errors.js +3 -1
  45. package/dist/lib/errors.js.map +1 -1
  46. package/dist/lib/grammar.js +3 -3
  47. package/dist/lib/import.d.ts +7 -4
  48. package/dist/lib/import.d.ts.map +1 -1
  49. package/dist/lib/import.js +58 -62
  50. package/dist/lib/import.js.map +1 -1
  51. package/dist/lib/macro-filter.lua +201 -201
  52. package/dist/lib/ooxml.d.ts +207 -0
  53. package/dist/lib/ooxml.d.ts.map +1 -0
  54. package/dist/lib/ooxml.js +634 -0
  55. package/dist/lib/ooxml.js.map +1 -0
  56. package/dist/lib/pdf-comments.js +44 -44
  57. package/dist/lib/plugins.js +57 -57
  58. package/dist/lib/pptx-color-filter.lua +37 -37
  59. package/dist/lib/pptx-themes.js +115 -115
  60. package/dist/lib/rate-limiter.d.ts +8 -0
  61. package/dist/lib/rate-limiter.d.ts.map +1 -1
  62. package/dist/lib/rate-limiter.js +41 -4
  63. package/dist/lib/rate-limiter.js.map +1 -1
  64. package/dist/lib/sections.d.ts.map +1 -1
  65. package/dist/lib/sections.js +5 -2
  66. package/dist/lib/sections.js.map +1 -1
  67. package/dist/lib/spelling.js +2 -2
  68. package/dist/lib/templates.js +387 -387
  69. package/dist/lib/themes.js +51 -51
  70. package/dist/lib/types.d.ts +9 -1
  71. package/dist/lib/types.d.ts.map +1 -1
  72. package/dist/lib/word-extraction.d.ts.map +1 -1
  73. package/dist/lib/word-extraction.js +48 -307
  74. package/dist/lib/word-extraction.js.map +1 -1
  75. package/dist/lib/word.d.ts.map +1 -1
  76. package/dist/lib/word.js +80 -175
  77. package/dist/lib/word.js.map +1 -1
  78. package/dist/lib/wordcomments.d.ts.map +1 -1
  79. package/dist/lib/wordcomments.js +53 -92
  80. package/dist/lib/wordcomments.js.map +1 -1
  81. package/docs-src/build.py +113 -113
  82. package/docs-src/extra.css +208 -208
  83. package/docs-src/md-to-html.lua +6 -6
  84. package/docs-src/template.html +116 -116
  85. package/eslint.config.js +27 -27
  86. package/lib/anchor-match.ts +307 -276
  87. package/lib/annotations.ts +664 -644
  88. package/lib/build.ts +1770 -1766
  89. package/lib/citations.ts +160 -160
  90. package/lib/commands/build.ts +860 -855
  91. package/lib/commands/citations.ts +515 -515
  92. package/lib/commands/comments.ts +1050 -1050
  93. package/lib/commands/context.ts +176 -176
  94. package/lib/commands/core.ts +309 -309
  95. package/lib/commands/doi.ts +451 -435
  96. package/lib/commands/file-ops.ts +372 -372
  97. package/lib/commands/history.ts +320 -320
  98. package/lib/commands/index.ts +87 -87
  99. package/lib/commands/init.ts +259 -259
  100. package/lib/commands/merge-resolve.ts +378 -378
  101. package/lib/commands/preview.ts +178 -178
  102. package/lib/commands/project-info.ts +244 -244
  103. package/lib/commands/quality.ts +517 -517
  104. package/lib/commands/response.ts +454 -454
  105. package/lib/commands/section-boundaries.ts +82 -82
  106. package/lib/commands/sections.ts +451 -451
  107. package/lib/commands/sync.ts +689 -709
  108. package/lib/commands/text-ops.ts +449 -449
  109. package/lib/commands/utilities.ts +448 -448
  110. package/lib/commands/verify-anchors.ts +272 -272
  111. package/lib/commands/word-tools.ts +340 -340
  112. package/lib/comment-realign.ts +99 -517
  113. package/lib/config.ts +84 -84
  114. package/lib/crossref.ts +781 -781
  115. package/lib/csl.ts +191 -191
  116. package/lib/dependencies.ts +132 -98
  117. package/lib/diff-engine.ts +465 -465
  118. package/lib/doi-cache.ts +115 -115
  119. package/lib/doi.ts +879 -897
  120. package/lib/equations.ts +506 -506
  121. package/lib/errors.ts +350 -346
  122. package/lib/format.ts +541 -541
  123. package/lib/git.ts +326 -326
  124. package/lib/grammar.ts +303 -303
  125. package/lib/image-registry.ts +180 -180
  126. package/lib/import.ts +906 -911
  127. package/lib/journals.ts +543 -543
  128. package/lib/macro-filter.lua +201 -201
  129. package/lib/macros.ts +273 -273
  130. package/lib/merge.ts +633 -633
  131. package/lib/ooxml.ts +768 -0
  132. package/lib/orcid.ts +144 -144
  133. package/lib/pdf-comments.ts +263 -263
  134. package/lib/pdf-import.ts +524 -524
  135. package/lib/plugins.ts +362 -362
  136. package/lib/postprocess.ts +188 -188
  137. package/lib/pptx-color-filter.lua +37 -37
  138. package/lib/pptx-template.ts +469 -469
  139. package/lib/pptx-themes.ts +483 -483
  140. package/lib/protect-restore.ts +520 -520
  141. package/lib/rate-limiter.ts +127 -94
  142. package/lib/response.ts +197 -197
  143. package/lib/restore-references.ts +240 -240
  144. package/lib/review.ts +327 -327
  145. package/lib/schema.ts +488 -488
  146. package/lib/scientific-words.ts +73 -73
  147. package/lib/sections.ts +428 -425
  148. package/lib/slides.ts +756 -756
  149. package/lib/spelling.ts +334 -334
  150. package/lib/templates.ts +526 -526
  151. package/lib/themes.ts +742 -742
  152. package/lib/trackchanges.ts +247 -247
  153. package/lib/tui.ts +450 -450
  154. package/lib/types.ts +558 -550
  155. package/lib/undo.ts +250 -250
  156. package/lib/utils.ts +69 -69
  157. package/lib/variables.ts +179 -179
  158. package/lib/word-extraction.ts +525 -806
  159. package/lib/word.ts +526 -643
  160. package/lib/wordcomments.ts +789 -840
  161. package/mkdocs.yml +64 -64
  162. package/package.json +137 -137
  163. package/scripts/postbuild.js +47 -47
  164. package/skill/REFERENCE.md +539 -539
  165. package/skill/SKILL.md +295 -295
  166. package/tsconfig.json +26 -26
  167. package/types/index.d.ts +525 -525
@@ -1,276 +1,307 @@
1
- /**
2
- * Anchor matching primitives shared between sync (insertion) and
3
- * verify-anchors (drift reporting). The functions are pure: given an
4
- * anchor string and surrounding context, locate candidate positions in
5
- * a target text using progressively looser strategies.
6
- */
7
-
8
- export type AnchorStrategy =
9
- | 'direct'
10
- | 'normalized'
11
- | 'stripped'
12
- | 'partial-start'
13
- | 'partial-start-stripped'
14
- | 'context-both'
15
- | 'context-before'
16
- | 'context-after'
17
- | 'split-match'
18
- | 'empty-anchor'
19
- | 'failed';
20
-
21
- export interface AnchorSearchResult {
22
- occurrences: number[];
23
- matchedAnchor: string | null;
24
- strategy: AnchorStrategy;
25
- stripped?: boolean;
26
- }
27
-
28
- /**
29
- * Strip CriticMarkup so the matcher sees plain prose instead of
30
- * `{++inserted++}`/`{--deleted--}`/etc. Used when an anchor lives
31
- * underneath previously imported track changes.
32
- */
33
- export function stripCriticMarkup(text: string): string {
34
- return text
35
- .replace(/\{\+\+([^+]*)\+\+\}/g, '$1') // insertions: keep new text
36
- .replace(/\{--([^-]*)--\}/g, '') // deletions: remove old text
37
- .replace(/\{~~([^~]*)~>([^~]*)~~\}/g, '$2') // substitutions: keep new text
38
- .replace(/\{>>[\s\S]*?<<\}/g, '') // comments: remove (non-greedy; comment text may contain '<')
39
- .replace(/\[([^\]]*)\]\{\.mark\}/g, '$1'); // marked text: keep text
40
- }
41
-
42
- /**
43
- * Return every starting index where `needle` occurs in `haystack`.
44
- * Empty needles return no occurrences (empty matches are not useful
45
- * for anchor placement).
46
- */
47
- /**
48
- * Score how well the docx-side `before` / `after` context matches the
49
- * surroundings of a candidate position in the target text. Used by
50
- * `verify-anchors` to tell apart "multiple hits but context picks one
51
- * cleanly" (sync will place it correctly) from "multiple hits, context
52
- * doesn't help" (truly ambiguous, needs human placement).
53
- *
54
- * Returns 0 if no context was provided.
55
- */
56
- export function scoreContextAt(
57
- pos: number,
58
- text: string,
59
- before: string,
60
- after: string,
61
- anchorLen: number,
62
- ): number {
63
- let score = 0;
64
- if (before) {
65
- const contextBefore = text.slice(Math.max(0, pos - before.length - 20), pos).toLowerCase();
66
- const beforeLower = before.toLowerCase();
67
- const beforeWords = beforeLower.split(/\s+/).filter(w => w.length > 3);
68
- for (const word of beforeWords) {
69
- if (contextBefore.includes(word)) score += 2;
70
- }
71
- if (contextBefore.includes(beforeLower.slice(-30))) score += 5;
72
- }
73
- if (after) {
74
- const contextAfter = text.slice(pos + anchorLen, pos + anchorLen + after.length + 20).toLowerCase();
75
- const afterLower = after.toLowerCase();
76
- const afterWords = afterLower.split(/\s+/).filter(w => w.length > 3);
77
- for (const word of afterWords) {
78
- if (contextAfter.includes(word)) score += 2;
79
- }
80
- if (contextAfter.includes(afterLower.slice(0, 30))) score += 5;
81
- }
82
- return score;
83
- }
84
-
85
- export function findAllOccurrences(haystack: string, needle: string): number[] {
86
- if (!needle || needle.length === 0) return [];
87
- const occurrences: number[] = [];
88
- let idx = 0;
89
- while ((idx = haystack.indexOf(needle, idx)) !== -1) {
90
- occurrences.push(idx);
91
- idx += 1;
92
- }
93
- return occurrences;
94
- }
95
-
96
- /**
97
- * Find candidate positions for `anchor` in `text`, falling back through
98
- * progressively looser strategies (whitespace normalization, stripped
99
- * CriticMarkup, partial-prefix, surrounding context, word splitting).
100
- *
101
- * The returned `strategy` lets callers distinguish a clean direct hit
102
- * from a fuzzy approximation useful for drift reporting.
103
- */
104
- export function findAnchorInText(
105
- anchor: string,
106
- text: string,
107
- before: string = '',
108
- after: string = ''
109
- ): AnchorSearchResult {
110
- // Empty anchor: skip directly to context-based matching
111
- if (!anchor || anchor.trim().length === 0) {
112
- if (before || after) {
113
- const beforeLower = (before || '').toLowerCase();
114
- const afterLower = (after || '').toLowerCase();
115
- const textLower = text.toLowerCase();
116
-
117
- if (before && after) {
118
- const beforeIdx = textLower.indexOf(beforeLower.slice(-50));
119
- if (beforeIdx !== -1) {
120
- const searchStart = beforeIdx + beforeLower.slice(-50).length;
121
- const afterIdx = textLower.indexOf(afterLower.slice(0, 50), searchStart);
122
- if (afterIdx !== -1 && afterIdx - searchStart < 500) {
123
- return { occurrences: [searchStart], matchedAnchor: null, strategy: 'context-both' };
124
- }
125
- }
126
- }
127
-
128
- if (before) {
129
- const beforeIdx = textLower.lastIndexOf(beforeLower.slice(-30));
130
- if (beforeIdx !== -1) {
131
- return {
132
- occurrences: [beforeIdx + beforeLower.slice(-30).length],
133
- matchedAnchor: null,
134
- strategy: 'context-before',
135
- };
136
- }
137
- }
138
-
139
- if (after) {
140
- const afterIdx = textLower.indexOf(afterLower.slice(0, 30));
141
- if (afterIdx !== -1) {
142
- return { occurrences: [afterIdx], matchedAnchor: null, strategy: 'context-after' };
143
- }
144
- }
145
- }
146
- return { occurrences: [], matchedAnchor: null, strategy: 'empty-anchor' };
147
- }
148
-
149
- const anchorLower = anchor.toLowerCase();
150
- const textLower = text.toLowerCase();
151
-
152
- // Strategy 1: direct match
153
- let occurrences = findAllOccurrences(textLower, anchorLower);
154
- if (occurrences.length > 0) {
155
- return { occurrences, matchedAnchor: anchor, strategy: 'direct' };
156
- }
157
-
158
- // Strategy 2: normalized whitespace
159
- const normalizedAnchor = anchor.replace(/\s+/g, ' ').toLowerCase();
160
- const normalizedText = text.replace(/\s+/g, ' ').toLowerCase();
161
- const idx = normalizedText.indexOf(normalizedAnchor);
162
- if (idx !== -1) {
163
- return { occurrences: [idx], matchedAnchor: anchor, strategy: 'normalized' };
164
- }
165
-
166
- // Strategy 3: match in stripped CriticMarkup version
167
- const strippedText = stripCriticMarkup(text);
168
- const strippedLower = strippedText.toLowerCase();
169
- occurrences = findAllOccurrences(strippedLower, anchorLower);
170
- if (occurrences.length > 0) {
171
- return { occurrences, matchedAnchor: anchor, strategy: 'stripped', stripped: true };
172
- }
173
-
174
- // Strategy 4: first N words of anchor (long anchors)
175
- const words = anchor.split(/\s+/);
176
- if (words.length > 3) {
177
- for (let n = Math.min(6, words.length); n >= 3; n--) {
178
- const partialAnchor = words.slice(0, n).join(' ').toLowerCase();
179
- if (partialAnchor.length >= 15) {
180
- occurrences = findAllOccurrences(textLower, partialAnchor);
181
- if (occurrences.length > 0) {
182
- return { occurrences, matchedAnchor: words.slice(0, n).join(' '), strategy: 'partial-start' };
183
- }
184
- occurrences = findAllOccurrences(strippedLower, partialAnchor);
185
- if (occurrences.length > 0) {
186
- return {
187
- occurrences,
188
- matchedAnchor: words.slice(0, n).join(' '),
189
- strategy: 'partial-start-stripped',
190
- stripped: true,
191
- };
192
- }
193
- }
194
- }
195
- }
196
-
197
- // Strategy 5: context (before/after) only
198
- if (before || after) {
199
- const beforeLower = before.toLowerCase();
200
- const afterLower = after.toLowerCase();
201
-
202
- if (before && after) {
203
- const beforeIdx = textLower.indexOf(beforeLower.slice(-50));
204
- if (beforeIdx !== -1) {
205
- const searchStart = beforeIdx + beforeLower.slice(-50).length;
206
- const afterIdx = textLower.indexOf(afterLower.slice(0, 50), searchStart);
207
- if (afterIdx !== -1 && afterIdx - searchStart < 500) {
208
- return { occurrences: [searchStart], matchedAnchor: null, strategy: 'context-both' };
209
- }
210
- }
211
- }
212
-
213
- if (before) {
214
- const beforeIdx = textLower.lastIndexOf(beforeLower.slice(-30));
215
- if (beforeIdx !== -1) {
216
- return {
217
- occurrences: [beforeIdx + beforeLower.slice(-30).length],
218
- matchedAnchor: null,
219
- strategy: 'context-before',
220
- };
221
- }
222
- }
223
-
224
- if (after) {
225
- const afterIdx = textLower.indexOf(afterLower.slice(0, 30));
226
- if (afterIdx !== -1) {
227
- return { occurrences: [afterIdx], matchedAnchor: null, strategy: 'context-after' };
228
- }
229
- }
230
- }
231
-
232
- // Strategy 6: split anchor on transition characters
233
- const splitPatterns = [' ', ', ', '. ', ' - ', ' – '];
234
- for (const sep of splitPatterns) {
235
- if (anchor.includes(sep)) {
236
- const parts = anchor.split(sep).filter(p => p.length >= 4);
237
- for (const part of parts) {
238
- const partLower = part.toLowerCase();
239
- occurrences = findAllOccurrences(textLower, partLower);
240
- if (occurrences.length > 0 && occurrences.length < 5) {
241
- return { occurrences, matchedAnchor: part, strategy: 'split-match' };
242
- }
243
- }
244
- }
245
- }
246
-
247
- return { occurrences: [], matchedAnchor: null, strategy: 'failed' };
248
- }
249
-
250
- /**
251
- * Classify a strategy as a clean hit, a fuzzy/drifted hit, or no hit.
252
- * Used by `verify-anchors` to summarize per-comment match quality.
253
- */
254
- export type AnchorMatchQuality = 'clean' | 'drift' | 'context-only' | 'unmatched';
255
-
256
- export function classifyStrategy(strategy: AnchorStrategy, occurrences: number): AnchorMatchQuality {
257
- if (occurrences === 0) return 'unmatched';
258
- switch (strategy) {
259
- case 'direct':
260
- case 'normalized':
261
- return 'clean';
262
- case 'stripped':
263
- case 'partial-start':
264
- case 'partial-start-stripped':
265
- case 'split-match':
266
- return 'drift';
267
- case 'context-both':
268
- case 'context-before':
269
- case 'context-after':
270
- return 'context-only';
271
- case 'empty-anchor':
272
- case 'failed':
273
- default:
274
- return 'unmatched';
275
- }
276
- }
1
+ /**
2
+ * Anchor matching primitives shared between sync (insertion) and
3
+ * verify-anchors (drift reporting). The functions are pure: given an
4
+ * anchor string and surrounding context, locate candidate positions in
5
+ * a target text using progressively looser strategies.
6
+ */
7
+
8
+ import { stripAnnotations } from './annotations.js';
9
+
10
+ export type AnchorStrategy =
11
+ | 'direct'
12
+ | 'normalized'
13
+ | 'stripped'
14
+ | 'partial-start'
15
+ | 'partial-start-stripped'
16
+ | 'partial-window'
17
+ | 'partial-window-stripped'
18
+ | 'context-both'
19
+ | 'context-before'
20
+ | 'context-after'
21
+ | 'split-match'
22
+ | 'empty-anchor'
23
+ | 'failed';
24
+
25
+ export interface AnchorSearchResult {
26
+ occurrences: number[];
27
+ matchedAnchor: string | null;
28
+ strategy: AnchorStrategy;
29
+ stripped?: boolean;
30
+ }
31
+
32
+ /**
33
+ * Strip CriticMarkup so the matcher sees plain prose instead of
34
+ * `{++inserted++}`/`{--deleted--}`/etc. Used when an anchor lives
35
+ * underneath previously imported track changes. Delegates to the single
36
+ * annotation stripper so the matcher and the build path resolve markup the
37
+ * same way a payload containing `+`, `-`, or `~` is handled correctly.
38
+ */
39
+ export function stripCriticMarkup(text: string): string {
40
+ return stripAnnotations(text);
41
+ }
42
+
43
+ /**
44
+ * Return every starting index where `needle` occurs in `haystack`.
45
+ * Empty needles return no occurrences (empty matches are not useful
46
+ * for anchor placement).
47
+ */
48
+ /**
49
+ * Score how well the docx-side `before` / `after` context matches the
50
+ * surroundings of a candidate position in the target text. Used by
51
+ * `verify-anchors` to tell apart "multiple hits but context picks one
52
+ * cleanly" (sync will place it correctly) from "multiple hits, context
53
+ * doesn't help" (truly ambiguous, needs human placement).
54
+ *
55
+ * Returns 0 if no context was provided.
56
+ */
57
+ export function scoreContextAt(
58
+ pos: number,
59
+ text: string,
60
+ before: string,
61
+ after: string,
62
+ anchorLen: number,
63
+ ): number {
64
+ let score = 0;
65
+ if (before) {
66
+ const contextBefore = text.slice(Math.max(0, pos - before.length - 20), pos).toLowerCase();
67
+ const beforeLower = before.toLowerCase();
68
+ const beforeWords = beforeLower.split(/\s+/).filter(w => w.length > 3);
69
+ for (const word of beforeWords) {
70
+ if (contextBefore.includes(word)) score += 2;
71
+ }
72
+ if (contextBefore.includes(beforeLower.slice(-30))) score += 5;
73
+ }
74
+ if (after) {
75
+ const contextAfter = text.slice(pos + anchorLen, pos + anchorLen + after.length + 20).toLowerCase();
76
+ const afterLower = after.toLowerCase();
77
+ const afterWords = afterLower.split(/\s+/).filter(w => w.length > 3);
78
+ for (const word of afterWords) {
79
+ if (contextAfter.includes(word)) score += 2;
80
+ }
81
+ if (contextAfter.includes(afterLower.slice(0, 30))) score += 5;
82
+ }
83
+ return score;
84
+ }
85
+
86
+ export function findAllOccurrences(haystack: string, needle: string): number[] {
87
+ if (!needle || needle.length === 0) return [];
88
+ const occurrences: number[] = [];
89
+ let idx = 0;
90
+ while ((idx = haystack.indexOf(needle, idx)) !== -1) {
91
+ occurrences.push(idx);
92
+ idx += 1;
93
+ }
94
+ return occurrences;
95
+ }
96
+
97
+ /**
98
+ * Find candidate positions for `anchor` in `text`, falling back through
99
+ * progressively looser strategies (whitespace normalization, stripped
100
+ * CriticMarkup, partial-prefix, surrounding context, word splitting).
101
+ *
102
+ * The returned `strategy` lets callers distinguish a clean direct hit
103
+ * from a fuzzy approximation — useful for drift reporting.
104
+ */
105
+ export function findAnchorInText(
106
+ anchor: string,
107
+ text: string,
108
+ before: string = '',
109
+ after: string = ''
110
+ ): AnchorSearchResult {
111
+ // Empty anchor: skip directly to context-based matching
112
+ if (!anchor || anchor.trim().length === 0) {
113
+ if (before || after) {
114
+ const beforeLower = (before || '').toLowerCase();
115
+ const afterLower = (after || '').toLowerCase();
116
+ const textLower = text.toLowerCase();
117
+
118
+ if (before && after) {
119
+ const beforeIdx = textLower.indexOf(beforeLower.slice(-50));
120
+ if (beforeIdx !== -1) {
121
+ const searchStart = beforeIdx + beforeLower.slice(-50).length;
122
+ const afterIdx = textLower.indexOf(afterLower.slice(0, 50), searchStart);
123
+ if (afterIdx !== -1 && afterIdx - searchStart < 500) {
124
+ return { occurrences: [searchStart], matchedAnchor: null, strategy: 'context-both' };
125
+ }
126
+ }
127
+ }
128
+
129
+ if (before) {
130
+ const beforeIdx = textLower.lastIndexOf(beforeLower.slice(-30));
131
+ if (beforeIdx !== -1) {
132
+ return {
133
+ occurrences: [beforeIdx + beforeLower.slice(-30).length],
134
+ matchedAnchor: null,
135
+ strategy: 'context-before',
136
+ };
137
+ }
138
+ }
139
+
140
+ if (after) {
141
+ const afterIdx = textLower.indexOf(afterLower.slice(0, 30));
142
+ if (afterIdx !== -1) {
143
+ return { occurrences: [afterIdx], matchedAnchor: null, strategy: 'context-after' };
144
+ }
145
+ }
146
+ }
147
+ return { occurrences: [], matchedAnchor: null, strategy: 'empty-anchor' };
148
+ }
149
+
150
+ const anchorLower = anchor.toLowerCase();
151
+ const textLower = text.toLowerCase();
152
+
153
+ // Strategy 1: direct match
154
+ let occurrences = findAllOccurrences(textLower, anchorLower);
155
+ if (occurrences.length > 0) {
156
+ return { occurrences, matchedAnchor: anchor, strategy: 'direct' };
157
+ }
158
+
159
+ // Strategy 2: normalized whitespace
160
+ const normalizedAnchor = anchor.replace(/\s+/g, ' ').toLowerCase();
161
+ const normalizedText = text.replace(/\s+/g, ' ').toLowerCase();
162
+ const idx = normalizedText.indexOf(normalizedAnchor);
163
+ if (idx !== -1) {
164
+ return { occurrences: [idx], matchedAnchor: anchor, strategy: 'normalized' };
165
+ }
166
+
167
+ // Strategy 3: match in stripped CriticMarkup version
168
+ const strippedText = stripCriticMarkup(text);
169
+ const strippedLower = strippedText.toLowerCase();
170
+ occurrences = findAllOccurrences(strippedLower, anchorLower);
171
+ if (occurrences.length > 0) {
172
+ return { occurrences, matchedAnchor: anchor, strategy: 'stripped', stripped: true };
173
+ }
174
+
175
+ // Strategy 4: word window from anchor (prefix or interior).
176
+ // Sliding the window across the anchor catches the case where the
177
+ // anchor's prefix has been edited but a chunk in the middle/end
178
+ // survived intact (e.g. "Sensitivity analyses were performed by
179
+ // perturbing the prior variance" → drifted "Sensitivity analyses
180
+ // perturbed the prior variance" still contains "the prior variance").
181
+ const words = anchor.split(/\s+/);
182
+ if (words.length > 3) {
183
+ for (let n = Math.min(6, words.length); n >= 3; n--) {
184
+ for (let start = 0; start + n <= words.length; start++) {
185
+ const window = words.slice(start, start + n).join(' ');
186
+ const windowLower = window.toLowerCase();
187
+ if (windowLower.length < 15) continue;
188
+
189
+ let occ = findAllOccurrences(textLower, windowLower);
190
+ if (occ.length > 0) {
191
+ const strategy: AnchorStrategy = start === 0 ? 'partial-start' : 'partial-window';
192
+ return { occurrences: occ, matchedAnchor: window, strategy };
193
+ }
194
+ occ = findAllOccurrences(strippedLower, windowLower);
195
+ if (occ.length > 0) {
196
+ const strategy: AnchorStrategy = start === 0 ? 'partial-start-stripped' : 'partial-window-stripped';
197
+ return { occurrences: occ, matchedAnchor: window, strategy, stripped: true };
198
+ }
199
+ }
200
+ }
201
+ }
202
+
203
+ // Strategy 5: context (before/after) only.
204
+ //
205
+ // For a non-empty anchor that already failed every text-based strategy
206
+ // above, we treat context as a degraded placement: classify it
207
+ // 'context-only' so callers can warn the user. We also reject
208
+ // implausible brackets if both contexts match but the gap between
209
+ // them is far too small to contain the anchor (e.g. the anchored
210
+ // sentence was deleted), do not silently land the comment between
211
+ // the surviving sentences. Return 'failed' so the user is told to
212
+ // place it manually.
213
+ if (before || after) {
214
+ const beforeLower = before.toLowerCase();
215
+ const afterLower = after.toLowerCase();
216
+ const anchorLen = anchor.length;
217
+
218
+ if (before && after) {
219
+ const beforeIdx = textLower.indexOf(beforeLower.slice(-50));
220
+ if (beforeIdx !== -1) {
221
+ const searchStart = beforeIdx + beforeLower.slice(-50).length;
222
+ const afterIdx = textLower.indexOf(afterLower.slice(0, 50), searchStart);
223
+ if (afterIdx !== -1) {
224
+ const gap = afterIdx - searchStart;
225
+ // Require the bracket to plausibly contain a remnant of the anchor.
226
+ // Below 30% of anchor length: anchor was deleted — refuse to place.
227
+ // Above anchor length + slack: brackets are too far apart, the
228
+ // matcher has latched onto unrelated repeats of common context.
229
+ const minGap = Math.floor(anchorLen * 0.3);
230
+ const maxGap = Math.min(500, anchorLen * 2 + 50);
231
+ if (gap >= minGap && gap <= maxGap) {
232
+ return { occurrences: [searchStart], matchedAnchor: null, strategy: 'context-both' };
233
+ }
234
+ // Both brackets found but gap implausible: anchor likely deleted.
235
+ // Don't fall back to single-side context — that would silently
236
+ // place the comment in the wrong location.
237
+ return { occurrences: [], matchedAnchor: null, strategy: 'failed' };
238
+ }
239
+ }
240
+ }
241
+
242
+ if (before) {
243
+ const beforeIdx = textLower.lastIndexOf(beforeLower.slice(-30));
244
+ if (beforeIdx !== -1) {
245
+ return {
246
+ occurrences: [beforeIdx + beforeLower.slice(-30).length],
247
+ matchedAnchor: null,
248
+ strategy: 'context-before',
249
+ };
250
+ }
251
+ }
252
+
253
+ if (after) {
254
+ const afterIdx = textLower.indexOf(afterLower.slice(0, 30));
255
+ if (afterIdx !== -1) {
256
+ return { occurrences: [afterIdx], matchedAnchor: null, strategy: 'context-after' };
257
+ }
258
+ }
259
+ }
260
+
261
+ // Strategy 6: split anchor on transition characters
262
+ const splitPatterns = [' ', ', ', '. ', ' - ', ' – '];
263
+ for (const sep of splitPatterns) {
264
+ if (anchor.includes(sep)) {
265
+ const parts = anchor.split(sep).filter(p => p.length >= 4);
266
+ for (const part of parts) {
267
+ const partLower = part.toLowerCase();
268
+ occurrences = findAllOccurrences(textLower, partLower);
269
+ if (occurrences.length > 0 && occurrences.length < 5) {
270
+ return { occurrences, matchedAnchor: part, strategy: 'split-match' };
271
+ }
272
+ }
273
+ }
274
+ }
275
+
276
+ return { occurrences: [], matchedAnchor: null, strategy: 'failed' };
277
+ }
278
+
279
+ /**
280
+ * Classify a strategy as a clean hit, a fuzzy/drifted hit, or no hit.
281
+ * Used by `verify-anchors` to summarize per-comment match quality.
282
+ */
283
+ export type AnchorMatchQuality = 'clean' | 'drift' | 'context-only' | 'unmatched';
284
+
285
+ export function classifyStrategy(strategy: AnchorStrategy, occurrences: number): AnchorMatchQuality {
286
+ if (occurrences === 0) return 'unmatched';
287
+ switch (strategy) {
288
+ case 'direct':
289
+ case 'normalized':
290
+ return 'clean';
291
+ case 'stripped':
292
+ case 'partial-start':
293
+ case 'partial-start-stripped':
294
+ case 'partial-window':
295
+ case 'partial-window-stripped':
296
+ case 'split-match':
297
+ return 'drift';
298
+ case 'context-both':
299
+ case 'context-before':
300
+ case 'context-after':
301
+ return 'context-only';
302
+ case 'empty-anchor':
303
+ case 'failed':
304
+ default:
305
+ return 'unmatched';
306
+ }
307
+ }