@mrsf/cli 0.6.0 → 0.7.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 (38) hide show
  1. package/README.md +12 -9
  2. package/dist/browser.cjs +1101 -24
  3. package/dist/browser.cjs.map +4 -4
  4. package/dist/browser.d.ts +11 -0
  5. package/dist/browser.js +8 -0
  6. package/dist/commands/add.js +12 -4
  7. package/dist/evaluation/generate-reanchor-cases.d.ts +10 -0
  8. package/dist/evaluation/generate-reanchor-cases.js +301 -0
  9. package/dist/evaluation/reanchor-eval.d.ts +111 -0
  10. package/dist/evaluation/reanchor-eval.js +266 -0
  11. package/dist/evaluation/reanchor-gates.d.ts +29 -0
  12. package/dist/evaluation/reanchor-gates.js +47 -0
  13. package/dist/evaluation/run-reanchor-eval.d.ts +3 -0
  14. package/dist/evaluation/run-reanchor-eval.js +103 -0
  15. package/dist/evaluation/run-reanchor-generator.d.ts +3 -0
  16. package/dist/evaluation/run-reanchor-generator.js +63 -0
  17. package/dist/evaluation/run-reanchor-profile.d.ts +3 -0
  18. package/dist/evaluation/run-reanchor-profile.js +129 -0
  19. package/dist/index.cjs +1226 -34
  20. package/dist/index.cjs.map +4 -4
  21. package/dist/index.d.ts +13 -0
  22. package/dist/index.js +10 -0
  23. package/dist/lib/anchor-context.d.ts +46 -0
  24. package/dist/lib/anchor-context.js +474 -0
  25. package/dist/lib/confidence-calibration.d.ts +16 -0
  26. package/dist/lib/confidence-calibration.js +120 -0
  27. package/dist/lib/fuzzy.d.ts +14 -1
  28. package/dist/lib/fuzzy.js +140 -19
  29. package/dist/lib/git.d.ts +9 -0
  30. package/dist/lib/git.js +30 -0
  31. package/dist/lib/global-reconciliation.d.ts +11 -0
  32. package/dist/lib/global-reconciliation.js +145 -0
  33. package/dist/lib/reanchor-core.d.ts +7 -0
  34. package/dist/lib/reanchor-core.js +46 -4
  35. package/dist/lib/reanchor.js +82 -11
  36. package/dist/lib/revision-projection.d.ts +21 -0
  37. package/dist/lib/revision-projection.js +203 -0
  38. package/package.json +5 -1
@@ -0,0 +1,120 @@
1
+ /**
2
+ * Calibrate independent revision and structural evidence into a public result.
3
+ *
4
+ * Exact evidence is certain. Edited anchors are probable only when evidence
5
+ * agrees or one source has a decisive margin; conflicting evidence abstains.
6
+ */
7
+ export function calibrateAnchorEvidence(commentId, selectedText, projected, contextual) {
8
+ if (projected?.exact) {
9
+ return {
10
+ band: "certain",
11
+ result: projectedResult(commentId, selectedText, projected),
12
+ };
13
+ }
14
+ if (contextual?.status === "anchored") {
15
+ return {
16
+ band: "certain",
17
+ result: contextualResult(commentId, selectedText, contextual),
18
+ };
19
+ }
20
+ if (projected && contextual) {
21
+ if (sameRange(projected, contextual)) {
22
+ const result = contextualResult(commentId, selectedText, contextual);
23
+ result.status = "fuzzy";
24
+ result.score = combineIndependentScores(projected.score, contextual.score);
25
+ result.reason =
26
+ `Probable anchor: revision projection and Markdown context agree `
27
+ + `(confidence ${result.score.toFixed(3)}).`;
28
+ return { band: "probable", result };
29
+ }
30
+ if (contextual.status === "orphaned" && !isStrongProjection(projected)) {
31
+ return {
32
+ band: "orphaned",
33
+ result: contextualResult(commentId, selectedText, contextual),
34
+ };
35
+ }
36
+ if (contextual.status === "fuzzy"
37
+ && contextual.candidateMargin >= 0.15
38
+ && contextual.score >= projected.score + 0.1) {
39
+ return {
40
+ band: "probable",
41
+ result: contextualResult(commentId, selectedText, contextual),
42
+ };
43
+ }
44
+ return {
45
+ band: "ambiguous",
46
+ result: {
47
+ commentId,
48
+ status: "ambiguous",
49
+ score: Math.max(projected.score, contextual.score),
50
+ reason: `Ambiguous evidence: revision projection points to line `
51
+ + `${projected.line}, while Markdown context points to `
52
+ + `${contextual.line ?? "no location"}.`,
53
+ },
54
+ };
55
+ }
56
+ if (contextual) {
57
+ const band = contextual.status === "orphaned"
58
+ ? "orphaned"
59
+ : contextual.status === "ambiguous"
60
+ ? "ambiguous"
61
+ : "probable";
62
+ return {
63
+ band,
64
+ result: contextualResult(commentId, selectedText, contextual),
65
+ };
66
+ }
67
+ if (projected) {
68
+ if (!isStrongProjection(projected))
69
+ return undefined;
70
+ return {
71
+ band: "probable",
72
+ result: projectedResult(commentId, selectedText, projected),
73
+ };
74
+ }
75
+ return undefined;
76
+ }
77
+ function isStrongProjection(projected) {
78
+ return projected.contextSupport >= 3
79
+ || (projected.contextSupport >= 2 && projected.contextMargin >= 0.5);
80
+ }
81
+ function sameRange(projected, contextual) {
82
+ return projected.line === contextual.line
83
+ && projected.endLine === contextual.endLine;
84
+ }
85
+ function combineIndependentScores(left, right) {
86
+ return Math.min(0.95, (left + right) / 2 + 0.05);
87
+ }
88
+ function projectedResult(commentId, selectedText, projected) {
89
+ return {
90
+ commentId,
91
+ status: projected.exact ? "anchored" : "fuzzy",
92
+ score: projected.score,
93
+ newLine: projected.line,
94
+ newEndLine: projected.endLine,
95
+ newStartColumn: projected.startColumn,
96
+ newEndColumn: projected.endColumn,
97
+ anchoredText: projected.exact ? undefined : projected.text,
98
+ previousSelectedText: projected.exact ? undefined : selectedText,
99
+ reason: projected.reason,
100
+ };
101
+ }
102
+ function contextualResult(commentId, selectedText, contextual) {
103
+ return {
104
+ commentId,
105
+ status: contextual.status,
106
+ score: contextual.score,
107
+ newLine: contextual.line,
108
+ newEndLine: contextual.endLine,
109
+ newStartColumn: contextual.startColumn,
110
+ newEndColumn: contextual.endColumn,
111
+ anchoredText: contextual.status === "fuzzy"
112
+ ? contextual.text
113
+ : undefined,
114
+ previousSelectedText: contextual.status === "fuzzy"
115
+ ? selectedText
116
+ : undefined,
117
+ reason: contextual.reason,
118
+ };
119
+ }
120
+ //# sourceMappingURL=confidence-calibration.js.map
@@ -5,6 +5,13 @@
5
5
  * Levenshtein matching for re-anchoring selected_text.
6
6
  */
7
7
  import type { FuzzyCandidate } from "./types.js";
8
+ export declare const MAX_FUZZY_CANDIDATE_LINES = 64;
9
+ export interface FuzzySearchIndex {
10
+ lines: string[];
11
+ tokenPostings: Map<string, number[]>;
12
+ trigramPostings: Map<string, number[]>;
13
+ }
14
+ export declare function createFuzzySearchIndex(lines: string[]): FuzzySearchIndex;
8
15
  /**
9
16
  * Find all exact occurrences of `needle` in lines (1-based array).
10
17
  */
@@ -35,5 +42,11 @@ export declare function combinedScore(needle: string, candidate: string): number
35
42
  * @param threshold Minimum score to include (0.0–1.0).
36
43
  * @param hintLine Optional original line number for proximity scoring.
37
44
  */
38
- export declare function fuzzySearch(lines: string[], needle: string, threshold?: number, hintLine?: number): FuzzyCandidate[];
45
+ export declare function fuzzySearch(lines: string[], needle: string, threshold?: number, hintLine?: number, index?: FuzzySearchIndex): FuzzyCandidate[];
46
+ /**
47
+ * Compute fuzzy candidates once and partition them by their unadjusted
48
+ * similarity thresholds. Proximity remains a ranking bonus and does not make
49
+ * a candidate eligible for a threshold it did not originally satisfy.
50
+ */
51
+ export declare function fuzzySearchThresholds(lines: string[], needle: string, thresholds: number[], hintLine?: number, index?: FuzzySearchIndex): Map<number, FuzzyCandidate[]>;
39
52
  //# sourceMappingURL=fuzzy.d.ts.map
package/dist/lib/fuzzy.js CHANGED
@@ -5,6 +5,16 @@
5
5
  * Levenshtein matching for re-anchoring selected_text.
6
6
  */
7
7
  import { distance as levenshtein } from "fastest-levenshtein";
8
+ export const MAX_FUZZY_CANDIDATE_LINES = 64;
9
+ export function createFuzzySearchIndex(lines) {
10
+ const tokenPostings = new Map();
11
+ const trigramPostings = new Map();
12
+ for (let line = 1; line < lines.length; line += 1) {
13
+ addPostingSignals(tokenPostings, lexicalTokens(lines[line]), line);
14
+ addPostingSignals(trigramPostings, characterTrigrams(lines[line]), line);
15
+ }
16
+ return { lines, tokenPostings, trigramPostings };
17
+ }
8
18
  // ---------------------------------------------------------------------------
9
19
  // Exact matching
10
20
  // ---------------------------------------------------------------------------
@@ -193,21 +203,47 @@ export function combinedScore(needle, candidate) {
193
203
  * @param threshold Minimum score to include (0.0–1.0).
194
204
  * @param hintLine Optional original line number for proximity scoring.
195
205
  */
196
- export function fuzzySearch(lines, needle, threshold = 0.6, hintLine) {
197
- if (!needle)
198
- return [];
206
+ export function fuzzySearch(lines, needle, threshold = 0.6, hintLine, index) {
207
+ return fuzzySearchThresholds(lines, needle, [threshold], hintLine, index).get(threshold) ?? [];
208
+ }
209
+ /**
210
+ * Compute fuzzy candidates once and partition them by their unadjusted
211
+ * similarity thresholds. Proximity remains a ranking bonus and does not make
212
+ * a candidate eligible for a threshold it did not originally satisfy.
213
+ */
214
+ export function fuzzySearchThresholds(lines, needle, thresholds, hintLine, index = createFuzzySearchIndex(lines)) {
215
+ const uniqueThresholds = [...new Set(thresholds)];
216
+ const results = new Map();
217
+ if (uniqueThresholds.length === 0)
218
+ return results;
219
+ if (!needle) {
220
+ for (const threshold of uniqueThresholds)
221
+ results.set(threshold, []);
222
+ return results;
223
+ }
199
224
  const needleLines = needle.split("\n");
200
225
  const needleLineCount = needleLines.length;
201
226
  const candidates = [];
227
+ const minimumThreshold = Math.min(...uniqueThresholds);
228
+ const candidateLines = retrieveCandidateLines(index, needle, hintLine);
202
229
  // Window sizes: ±30% of original line count, minimum 1
203
230
  const minWindow = Math.max(1, Math.floor(needleLineCount * 0.7));
204
231
  const maxWindow = Math.min(lines.length - 1, Math.ceil(needleLineCount * 1.3) + 1);
205
232
  for (let winSize = minWindow; winSize <= maxWindow; winSize++) {
206
- for (let startLine = 1; startLine + winSize - 1 < lines.length; startLine++) {
233
+ const startLines = new Set();
234
+ for (const candidateLine of candidateLines) {
235
+ for (let offset = 0; offset < winSize; offset += 1) {
236
+ const startLine = candidateLine - offset;
237
+ if (startLine >= 1 && startLine + winSize - 1 < lines.length) {
238
+ startLines.add(startLine);
239
+ }
240
+ }
241
+ }
242
+ for (const startLine of startLines) {
207
243
  const windowLines = lines.slice(startLine, startLine + winSize);
208
244
  const windowText = windowLines.join("\n");
209
245
  const score = combinedScore(needle, windowText);
210
- if (score >= threshold) {
246
+ if (score >= minimumThreshold) {
211
247
  candidates.push({
212
248
  text: windowText,
213
249
  line: startLine,
@@ -221,19 +257,19 @@ export function fuzzySearch(lines, needle, threshold = 0.6, hintLine) {
221
257
  }
222
258
  // For single-line needles, also try substring matching within each line
223
259
  if (needleLineCount === 1 && needle.length < 200) {
224
- for (let lineNum = 1; lineNum < lines.length; lineNum++) {
260
+ for (const lineNum of candidateLines) {
225
261
  const line = lines[lineNum];
226
262
  if (!line)
227
263
  continue;
228
- // Sliding window within line
229
264
  const winLen = needle.length;
230
265
  const minWinLen = Math.max(3, Math.floor(winLen * 0.7));
231
266
  const maxWinLen = Math.min(line.length, Math.ceil(winLen * 1.3));
232
- for (let len = minWinLen; len <= maxWinLen; len++) {
267
+ const lengths = evenlySpacedIntegers(minWinLen, maxWinLen, 7);
268
+ for (const len of lengths) {
233
269
  for (let col = 0; col + len <= line.length; col++) {
234
270
  const sub = line.substring(col, col + len);
235
271
  const score = combinedScore(needle, sub);
236
- if (score >= threshold) {
272
+ if (score >= minimumThreshold) {
237
273
  candidates.push({
238
274
  text: sub,
239
275
  line: lineNum,
@@ -247,18 +283,93 @@ export function fuzzySearch(lines, needle, threshold = 0.6, hintLine) {
247
283
  }
248
284
  }
249
285
  }
250
- // Deduplicate and sort
251
- let deduped = deduplicateCandidates(candidates);
252
- // Apply proximity bonus if hintLine is provided
286
+ const deduped = deduplicateCandidates(candidates);
287
+ const scored = deduped.map((candidate) => ({
288
+ baseScore: candidate.score,
289
+ candidate: applyProximityBonus(candidate, hintLine),
290
+ }));
291
+ for (const threshold of uniqueThresholds) {
292
+ results.set(threshold, scored
293
+ .filter((item) => item.baseScore >= threshold)
294
+ .map((item) => item.candidate)
295
+ .sort((left, right) => right.score - left.score));
296
+ }
297
+ return results;
298
+ }
299
+ function retrieveCandidateLines(index, needle, hintLine) {
300
+ const votes = new Map();
301
+ const lineCount = Math.max(0, index.lines.length - 1);
302
+ const signals = [
303
+ ...postingSignals(index.tokenPostings, lexicalTokens(needle), 2),
304
+ ...postingSignals(index.trigramPostings, characterTrigrams(needle), 1),
305
+ ]
306
+ .sort((left, right) => left.postings.length - right.postings.length)
307
+ .slice(0, 16);
308
+ for (const signal of signals) {
309
+ const rarity = Math.log1p(lineCount / signal.postings.length);
310
+ for (const line of signal.postings) {
311
+ votes.set(line, (votes.get(line) ?? 0) + signal.weight * rarity);
312
+ }
313
+ }
253
314
  if (hintLine != null) {
254
- deduped = deduped.map((c) => {
255
- const dist = Math.abs(c.line - hintLine);
256
- // Small bonus for proximity (up to 0.1 for exact line match)
257
- const proximityBonus = 0.1 * Math.max(0, 1 - dist / 50);
258
- return { ...c, score: Math.min(1.0, c.score + proximityBonus) };
259
- });
315
+ for (let offset = -4; offset <= 4; offset += 1) {
316
+ const line = hintLine + offset;
317
+ if (line >= 1 && line <= lineCount) {
318
+ votes.set(line, (votes.get(line) ?? 0) + 0.25);
319
+ }
320
+ }
321
+ }
322
+ if (votes.size === 0) {
323
+ return Array.from({ length: lineCount }, (_, index) => index + 1);
324
+ }
325
+ return [...votes.entries()]
326
+ .sort((left, right) => right[1] - left[1]
327
+ || distanceFromHint(left[0], hintLine) - distanceFromHint(right[0], hintLine)
328
+ || left[0] - right[0])
329
+ .slice(0, MAX_FUZZY_CANDIDATE_LINES)
330
+ .map(([line]) => line);
331
+ }
332
+ function postingSignals(postings, values, weight) {
333
+ return [...new Set(values)]
334
+ .map((value) => ({ postings: postings.get(value) ?? [], weight }))
335
+ .filter((signal) => signal.postings.length > 0);
336
+ }
337
+ function addPostingSignals(postings, values, line) {
338
+ for (const value of new Set(values)) {
339
+ const lines = postings.get(value);
340
+ if (lines) {
341
+ lines.push(line);
342
+ }
343
+ else {
344
+ postings.set(value, [line]);
345
+ }
346
+ }
347
+ }
348
+ function lexicalTokens(text) {
349
+ return text.toLowerCase().match(/[\p{L}\p{N}_-]+/gu) ?? [];
350
+ }
351
+ function characterTrigrams(text) {
352
+ const normalized = text.toLowerCase().replace(/\s+/g, " ").trim();
353
+ const characters = [...normalized];
354
+ if (characters.length < 3)
355
+ return normalized ? [normalized] : [];
356
+ const trigrams = [];
357
+ for (let index = 0; index <= characters.length - 3; index += 1) {
358
+ trigrams.push(characters.slice(index, index + 3).join(""));
260
359
  }
261
- return deduped.sort((a, b) => b.score - a.score);
360
+ return trigrams;
361
+ }
362
+ function evenlySpacedIntegers(minimum, maximum, count) {
363
+ if (maximum <= minimum)
364
+ return [minimum];
365
+ const values = new Set();
366
+ for (let index = 0; index < count; index += 1) {
367
+ values.add(Math.round(minimum + (maximum - minimum) * index / (count - 1)));
368
+ }
369
+ return [...values];
370
+ }
371
+ function distanceFromHint(line, hintLine) {
372
+ return hintLine == null ? 0 : Math.abs(line - hintLine);
262
373
  }
263
374
  // ---------------------------------------------------------------------------
264
375
  // Helpers
@@ -274,4 +385,14 @@ function deduplicateCandidates(candidates) {
274
385
  }
275
386
  return Array.from(seen.values());
276
387
  }
388
+ function applyProximityBonus(candidate, hintLine) {
389
+ if (hintLine == null)
390
+ return candidate;
391
+ const distance = Math.abs(candidate.line - hintLine);
392
+ const proximityBonus = 0.1 * Math.max(0, 1 - distance / 50);
393
+ return {
394
+ ...candidate,
395
+ score: Math.min(1.0, candidate.score + proximityBonus),
396
+ };
397
+ }
277
398
  //# sourceMappingURL=fuzzy.js.map
package/dist/lib/git.d.ts CHANGED
@@ -16,10 +16,19 @@ export declare function resetGitCache(): void;
16
16
  * Returns null if not in a Git repo.
17
17
  */
18
18
  export declare function findRepoRoot(cwd?: string): Promise<string | null>;
19
+ /**
20
+ * Read the repository-local Git author name.
21
+ *
22
+ * Only `.git/config` is considered; global Git identity is intentionally
23
+ * ignored so callers can distinguish repository identity from user defaults.
24
+ */
25
+ export declare function getGitUserName(repoRoot: string): Promise<string | null>;
19
26
  /**
20
27
  * Get the full HEAD commit SHA.
21
28
  */
22
29
  export declare function getCurrentCommit(repoRoot: string): Promise<string | null>;
30
+ /** Resolve a revision name or abbreviated SHA to its canonical commit SHA. */
31
+ export declare function resolveCommit(revision: string, repoRoot: string): Promise<string | null>;
23
32
  /**
24
33
  * Check if a commit hash is the same as HEAD.
25
34
  */
package/dist/lib/git.js CHANGED
@@ -49,6 +49,24 @@ export async function findRepoRoot(cwd) {
49
49
  return null;
50
50
  }
51
51
  }
52
+ /**
53
+ * Read the repository-local Git author name.
54
+ *
55
+ * Only `.git/config` is considered; global Git identity is intentionally
56
+ * ignored so callers can distinguish repository identity from user defaults.
57
+ */
58
+ export async function getGitUserName(repoRoot) {
59
+ if (!(await isGitAvailable()))
60
+ return null;
61
+ try {
62
+ const { stdout } = await execFile("git", ["config", "--local", "--get", "user.name"], { cwd: repoRoot, timeout: GIT_TIMEOUT });
63
+ const name = stdout.trim();
64
+ return name || null;
65
+ }
66
+ catch {
67
+ return null;
68
+ }
69
+ }
52
70
  /**
53
71
  * Get the full HEAD commit SHA.
54
72
  */
@@ -63,6 +81,18 @@ export async function getCurrentCommit(repoRoot) {
63
81
  return null;
64
82
  }
65
83
  }
84
+ /** Resolve a revision name or abbreviated SHA to its canonical commit SHA. */
85
+ export async function resolveCommit(revision, repoRoot) {
86
+ if (!(await isGitAvailable()))
87
+ return null;
88
+ try {
89
+ const { stdout } = await execFile("git", ["rev-parse", "--verify", `${revision}^{commit}`], { cwd: repoRoot, timeout: GIT_TIMEOUT });
90
+ return stdout.trim();
91
+ }
92
+ catch {
93
+ return null;
94
+ }
95
+ }
66
96
  /**
67
97
  * Check if a commit hash is the same as HEAD.
68
98
  */
@@ -0,0 +1,11 @@
1
+ import { type AnchorContextIndex } from "./anchor-context.js";
2
+ import type { Comment, ReanchorResult } from "./types.js";
3
+ /**
4
+ * Resolve uncertain comments from nearby high-confidence anchors.
5
+ *
6
+ * Each landmark votes for a local source-to-target displacement rather than
7
+ * global document order, allowing whole sections to move independently.
8
+ * Newly confirmed exact anchors become landmarks in subsequent rounds.
9
+ */
10
+ export declare function reconcileCommentAnchors(comments: Comment[], results: ReanchorResult[], anchorContext: AnchorContextIndex): ReanchorResult[];
11
+ //# sourceMappingURL=global-reconciliation.d.ts.map
@@ -0,0 +1,145 @@
1
+ import { findContextAnchorCandidates, getAnchorContextScope, } from "./anchor-context.js";
2
+ const LOCAL_LANDMARK_WINDOW = 30;
3
+ const MIN_LANDMARK_SUPPORT = 0.65;
4
+ const MIN_SUPPORTING_LANDMARKS = 2;
5
+ const MIN_GLOBAL_MARGIN = 0.08;
6
+ const MAX_RECONCILIATION_ROUNDS = 4;
7
+ /**
8
+ * Resolve uncertain comments from nearby high-confidence anchors.
9
+ *
10
+ * Each landmark votes for a local source-to-target displacement rather than
11
+ * global document order, allowing whole sections to move independently.
12
+ * Newly confirmed exact anchors become landmarks in subsequent rounds.
13
+ */
14
+ export function reconcileCommentAnchors(comments, results, anchorContext) {
15
+ const reconciled = results.map((result) => ({ ...result }));
16
+ if (comments.length < MIN_SUPPORTING_LANDMARKS + 1
17
+ || !reconciled.some((result) => result.status === "ambiguous")) {
18
+ return reconciled;
19
+ }
20
+ const resultIndexes = new Map(reconciled.map((result, index) => [result.commentId, index]));
21
+ const landmarks = collectLandmarks(comments, reconciled, anchorContext);
22
+ const landmarkCounts = new Map();
23
+ for (const landmark of landmarks) {
24
+ landmarkCounts.set(landmark.scope, (landmarkCounts.get(landmark.scope) ?? 0) + 1);
25
+ }
26
+ for (let round = 0; round < MAX_RECONCILIATION_ROUNDS; round += 1) {
27
+ let changed = false;
28
+ for (const comment of comments) {
29
+ if (comment.line == null || !comment.selected_text)
30
+ continue;
31
+ const scope = getAnchorContextScope(comment, anchorContext);
32
+ if (scope == null)
33
+ continue;
34
+ if ((landmarkCounts.get(scope) ?? 0) < MIN_SUPPORTING_LANDMARKS) {
35
+ continue;
36
+ }
37
+ const resultIndex = resultIndexes.get(comment.id);
38
+ if (resultIndex == null)
39
+ continue;
40
+ const result = reconciled[resultIndex];
41
+ if (result.status !== "ambiguous")
42
+ continue;
43
+ const candidates = deduplicateCandidates(findContextAnchorCandidates(comment, anchorContext)).slice(0, 8);
44
+ if (candidates.length < 2)
45
+ continue;
46
+ const ranked = candidates
47
+ .map((candidate) => scoreWithLandmarks(comment.line, scope, candidate, landmarks))
48
+ .sort((left, right) => right.globalScore - left.globalScore
49
+ || right.candidate.score - left.candidate.score
50
+ || left.candidate.line - right.candidate.line);
51
+ const best = ranked[0];
52
+ const second = ranked[1];
53
+ if (!best.candidate.exact
54
+ || best.supportingLandmarks < MIN_SUPPORTING_LANDMARKS
55
+ || best.support < MIN_LANDMARK_SUPPORT
56
+ || best.globalScore - second.globalScore < MIN_GLOBAL_MARGIN) {
57
+ continue;
58
+ }
59
+ reconciled[resultIndex] = {
60
+ commentId: comment.id,
61
+ status: "anchored",
62
+ score: Math.min(0.99, 0.8 + best.support * 0.19),
63
+ newLine: best.candidate.line,
64
+ newEndLine: best.candidate.endLine,
65
+ newStartColumn: best.candidate.startColumn,
66
+ newEndColumn: best.candidate.endColumn,
67
+ reason: `Global reconciliation selected this candidate from `
68
+ + `${best.supportingLandmarks} nearby landmark(s) `
69
+ + `(support ${best.support.toFixed(3)}, margin `
70
+ + `${(best.globalScore - second.globalScore).toFixed(3)}).`,
71
+ };
72
+ changed = true;
73
+ landmarks.push({
74
+ sourceLine: comment.line,
75
+ targetLine: best.candidate.line,
76
+ scope,
77
+ });
78
+ landmarkCounts.set(scope, (landmarkCounts.get(scope) ?? 0) + 1);
79
+ }
80
+ if (!changed)
81
+ break;
82
+ }
83
+ return reconciled;
84
+ }
85
+ function collectLandmarks(comments, results, anchorContext) {
86
+ const resultMap = new Map(results.map((result) => [result.commentId, result]));
87
+ return comments.flatMap((comment) => {
88
+ const result = resultMap.get(comment.id);
89
+ if (comment.line == null
90
+ || result?.newLine == null
91
+ || (result.status !== "anchored" && result.status !== "shifted")
92
+ || result.score < 0.99) {
93
+ return [];
94
+ }
95
+ const scope = getAnchorContextScope(comment, anchorContext);
96
+ return scope == null
97
+ ? []
98
+ : [{ sourceLine: comment.line, targetLine: result.newLine, scope }];
99
+ });
100
+ }
101
+ function scoreWithLandmarks(sourceLine, scope, candidate, landmarks) {
102
+ let weightedSupport = 0;
103
+ let totalWeight = 0;
104
+ let supportingLandmarks = 0;
105
+ const candidateShift = candidate.line - sourceLine;
106
+ for (const landmark of landmarks) {
107
+ if (landmark.scope !== scope)
108
+ continue;
109
+ const sourceDistance = Math.abs(sourceLine - landmark.sourceLine);
110
+ if (sourceDistance === 0 || sourceDistance > LOCAL_LANDMARK_WINDOW)
111
+ continue;
112
+ const landmarkShift = landmark.targetLine - landmark.sourceLine;
113
+ const shiftError = Math.abs(candidateShift - landmarkShift);
114
+ const agreement = Math.max(0, 1 - shiftError / 8);
115
+ const weight = 1 / (1 + sourceDistance / 4);
116
+ weightedSupport += agreement * weight;
117
+ totalWeight += weight;
118
+ if (agreement >= MIN_LANDMARK_SUPPORT)
119
+ supportingLandmarks += 1;
120
+ }
121
+ const support = totalWeight > 0 ? weightedSupport / totalWeight : 0;
122
+ return {
123
+ candidate,
124
+ globalScore: candidate.score * 0.5 + support * 0.45 + (candidate.exact ? 0.05 : 0),
125
+ support,
126
+ supportingLandmarks,
127
+ };
128
+ }
129
+ function deduplicateCandidates(candidates) {
130
+ const unique = new Map();
131
+ for (const candidate of candidates) {
132
+ const key = [
133
+ candidate.line,
134
+ candidate.endLine,
135
+ candidate.startColumn ?? "",
136
+ candidate.endColumn ?? "",
137
+ ].join(":");
138
+ const existing = unique.get(key);
139
+ if (!existing || candidate.score > existing.score) {
140
+ unique.set(key, candidate);
141
+ }
142
+ }
143
+ return [...unique.values()].sort((left, right) => right.score - left.score || left.line - right.line);
144
+ }
145
+ //# sourceMappingURL=global-reconciliation.js.map
@@ -1,4 +1,7 @@
1
1
  import type { AnchorPosition, Comment, DiffHunk, MrsfDocument, ReanchorResult } from "./types.js";
2
+ import { type FuzzySearchIndex } from "./fuzzy.js";
3
+ import { type RevisionProjectionIndex } from "./revision-projection.js";
4
+ import { type AnchorContextIndex } from "./anchor-context.js";
2
5
  export declare const HIGH_THRESHOLD = 0.8;
3
6
  export declare const DEFAULT_THRESHOLD = 0.6;
4
7
  /**
@@ -16,6 +19,10 @@ export declare function reanchorComment(comment: Comment, documentLines: string[
16
19
  threshold?: number;
17
20
  commitIsStale?: boolean;
18
21
  proximityWindow?: number;
22
+ revisionProjection?: RevisionProjectionIndex;
23
+ anchorContext?: AnchorContextIndex;
24
+ fuzzySearchIndex?: FuzzySearchIndex;
25
+ getFuzzySearchIndex?: () => FuzzySearchIndex;
19
26
  }): ReanchorResult;
20
27
  export declare function reanchorDocumentLines(doc: MrsfDocument, documentLines: string[], opts?: {
21
28
  threshold?: number;