@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.
- package/README.md +12 -9
- package/dist/browser.cjs +1101 -24
- package/dist/browser.cjs.map +4 -4
- package/dist/browser.d.ts +11 -0
- package/dist/browser.js +8 -0
- package/dist/commands/add.js +12 -4
- package/dist/evaluation/generate-reanchor-cases.d.ts +10 -0
- package/dist/evaluation/generate-reanchor-cases.js +301 -0
- package/dist/evaluation/reanchor-eval.d.ts +111 -0
- package/dist/evaluation/reanchor-eval.js +266 -0
- package/dist/evaluation/reanchor-gates.d.ts +29 -0
- package/dist/evaluation/reanchor-gates.js +47 -0
- package/dist/evaluation/run-reanchor-eval.d.ts +3 -0
- package/dist/evaluation/run-reanchor-eval.js +103 -0
- package/dist/evaluation/run-reanchor-generator.d.ts +3 -0
- package/dist/evaluation/run-reanchor-generator.js +63 -0
- package/dist/evaluation/run-reanchor-profile.d.ts +3 -0
- package/dist/evaluation/run-reanchor-profile.js +129 -0
- package/dist/index.cjs +1226 -34
- package/dist/index.cjs.map +4 -4
- package/dist/index.d.ts +13 -0
- package/dist/index.js +10 -0
- package/dist/lib/anchor-context.d.ts +46 -0
- package/dist/lib/anchor-context.js +474 -0
- package/dist/lib/confidence-calibration.d.ts +16 -0
- package/dist/lib/confidence-calibration.js +120 -0
- package/dist/lib/fuzzy.d.ts +14 -1
- package/dist/lib/fuzzy.js +140 -19
- package/dist/lib/git.d.ts +9 -0
- package/dist/lib/git.js +30 -0
- package/dist/lib/global-reconciliation.d.ts +11 -0
- package/dist/lib/global-reconciliation.js +145 -0
- package/dist/lib/reanchor-core.d.ts +7 -0
- package/dist/lib/reanchor-core.js +46 -4
- package/dist/lib/reanchor.js +82 -11
- package/dist/lib/revision-projection.d.ts +21 -0
- package/dist/lib/revision-projection.js +203 -0
- 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
|
package/dist/lib/fuzzy.d.ts
CHANGED
|
@@ -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
|
-
|
|
198
|
-
|
|
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
|
-
|
|
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 >=
|
|
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 (
|
|
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
|
-
|
|
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 >=
|
|
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
|
-
|
|
251
|
-
|
|
252
|
-
|
|
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
|
-
|
|
255
|
-
const
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
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
|
|
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;
|