@mrsf/cli 0.6.0 → 0.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +10 -7
- 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/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 +1209 -34
- package/dist/index.cjs.map +4 -4
- package/dist/index.d.ts +12 -0
- package/dist/index.js +9 -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 +2 -0
- package/dist/lib/git.js +12 -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
package/dist/lib/reanchor.js
CHANGED
|
@@ -11,8 +11,12 @@
|
|
|
11
11
|
* Step 3 – lower-threshold fuzzy ≥ configured threshold (0.6)
|
|
12
12
|
* Step 4 – orphan
|
|
13
13
|
*/
|
|
14
|
-
import { findRepoRoot, getCurrentCommit, getDiff, isGitAvailable, } from "./git.js";
|
|
15
|
-
import { applyReanchorResults, DEFAULT_THRESHOLD, reanchorComment, reanchorDocumentLines, } from "./reanchor-core.js";
|
|
14
|
+
import { findRepoRoot, getCurrentCommit, getDiff, getFileAtCommit, isGitAvailable, resolveCommit, } from "./git.js";
|
|
15
|
+
import { applyReanchorResults, DEFAULT_THRESHOLD, reanchorComment, reanchorDocumentLines, toReanchorLines, } from "./reanchor-core.js";
|
|
16
|
+
import { createRevisionProjection, } from "./revision-projection.js";
|
|
17
|
+
import { createAnchorContextIndex, } from "./anchor-context.js";
|
|
18
|
+
import { createFuzzySearchIndex, } from "./fuzzy.js";
|
|
19
|
+
import { reconcileCommentAnchors } from "./global-reconciliation.js";
|
|
16
20
|
import { readDocumentLines } from "./parser.js";
|
|
17
21
|
import { sidecarToDocument } from "./discovery.js";
|
|
18
22
|
import { parseSidecar } from "./parser.js";
|
|
@@ -32,9 +36,11 @@ export async function reanchorDocument(doc, documentLines, opts = {}) {
|
|
|
32
36
|
const results = [];
|
|
33
37
|
const threshold = opts.threshold ?? DEFAULT_THRESHOLD;
|
|
34
38
|
const proximityWindow = opts.proximityWindow;
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
39
|
+
let fuzzySearchIndex;
|
|
40
|
+
const getFuzzySearchIndex = () => {
|
|
41
|
+
fuzzySearchIndex ??= createFuzzySearchIndex(documentLines);
|
|
42
|
+
return fuzzySearchIndex;
|
|
43
|
+
};
|
|
38
44
|
if (!opts.noGit && (await isGitAvailable())) {
|
|
39
45
|
const repoRoot = opts.repoRoot ?? (await findRepoRoot(opts.cwd));
|
|
40
46
|
if (repoRoot && opts.documentPath) {
|
|
@@ -42,23 +48,88 @@ export async function reanchorDocument(doc, documentLines, opts = {}) {
|
|
|
42
48
|
const head = await getCurrentCommit(repoRoot);
|
|
43
49
|
// Use a shared fromCommit for all comments, or per-comment
|
|
44
50
|
const globalFrom = opts.fromCommit;
|
|
51
|
+
const diffCache = new Map();
|
|
52
|
+
const projectionCache = new Map();
|
|
53
|
+
const contextCache = new Map();
|
|
54
|
+
const canonicalCommitCache = new Map();
|
|
55
|
+
const reconciliationGroups = new Map();
|
|
45
56
|
for (const comment of doc.comments) {
|
|
46
|
-
const
|
|
47
|
-
if (
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
57
|
+
const rawCommentCommit = globalFrom ?? comment.commit;
|
|
58
|
+
if (rawCommentCommit && head) {
|
|
59
|
+
const cachedCommit = canonicalCommitCache.get(rawCommentCommit);
|
|
60
|
+
const commentCommit = cachedCommit
|
|
61
|
+
?? await resolveCommit(rawCommentCommit, repoRoot)
|
|
62
|
+
?? rawCommentCommit;
|
|
63
|
+
if (!cachedCommit) {
|
|
64
|
+
canonicalCommitCache.set(rawCommentCommit, commentCommit);
|
|
65
|
+
}
|
|
66
|
+
if (commentCommit === head) {
|
|
67
|
+
results.push(reanchorComment(comment, documentLines, {
|
|
68
|
+
threshold,
|
|
69
|
+
commitIsStale: false,
|
|
70
|
+
proximityWindow,
|
|
71
|
+
getFuzzySearchIndex,
|
|
72
|
+
}));
|
|
73
|
+
continue;
|
|
74
|
+
}
|
|
75
|
+
let hunks = diffCache.get(commentCommit);
|
|
76
|
+
if (!hunks) {
|
|
77
|
+
hunks = await getDiff(commentCommit, head, relPath, repoRoot);
|
|
78
|
+
diffCache.set(commentCommit, hunks);
|
|
79
|
+
}
|
|
80
|
+
let revisionProjection = projectionCache.get(commentCommit);
|
|
81
|
+
let anchorContext = contextCache.get(commentCommit);
|
|
82
|
+
if (!projectionCache.has(commentCommit)) {
|
|
83
|
+
const sourceText = await getFileAtCommit(commentCommit, relPath, repoRoot);
|
|
84
|
+
revisionProjection = sourceText == null
|
|
85
|
+
? undefined
|
|
86
|
+
: createRevisionProjection(toReanchorLines(sourceText), documentLines);
|
|
87
|
+
anchorContext = sourceText == null
|
|
88
|
+
? undefined
|
|
89
|
+
: createAnchorContextIndex(toReanchorLines(sourceText), documentLines);
|
|
90
|
+
projectionCache.set(commentCommit, revisionProjection);
|
|
91
|
+
contextCache.set(commentCommit, anchorContext);
|
|
92
|
+
}
|
|
51
93
|
const result = reanchorComment(comment, documentLines, {
|
|
52
94
|
diffHunks: hunks,
|
|
53
95
|
threshold,
|
|
54
96
|
commitIsStale: true,
|
|
55
97
|
proximityWindow,
|
|
98
|
+
revisionProjection,
|
|
99
|
+
anchorContext,
|
|
100
|
+
getFuzzySearchIndex,
|
|
56
101
|
});
|
|
102
|
+
const resultIndex = results.length;
|
|
57
103
|
results.push(result);
|
|
104
|
+
if (anchorContext) {
|
|
105
|
+
const group = reconciliationGroups.get(commentCommit);
|
|
106
|
+
if (group) {
|
|
107
|
+
group.comments.push(comment);
|
|
108
|
+
group.resultIndexes.push(resultIndex);
|
|
109
|
+
}
|
|
110
|
+
else {
|
|
111
|
+
reconciliationGroups.set(commentCommit, {
|
|
112
|
+
comments: [comment],
|
|
113
|
+
resultIndexes: [resultIndex],
|
|
114
|
+
anchorContext,
|
|
115
|
+
});
|
|
116
|
+
}
|
|
117
|
+
}
|
|
58
118
|
continue;
|
|
59
119
|
}
|
|
60
120
|
// non-stale or no commit
|
|
61
|
-
results.push(reanchorComment(comment, documentLines, {
|
|
121
|
+
results.push(reanchorComment(comment, documentLines, {
|
|
122
|
+
threshold,
|
|
123
|
+
commitIsStale: false,
|
|
124
|
+
proximityWindow,
|
|
125
|
+
getFuzzySearchIndex,
|
|
126
|
+
}));
|
|
127
|
+
}
|
|
128
|
+
for (const group of reconciliationGroups.values()) {
|
|
129
|
+
const reconciled = reconcileCommentAnchors(group.comments, group.resultIndexes.map((index) => results[index]), group.anchorContext);
|
|
130
|
+
for (const [offset, resultIndex] of group.resultIndexes.entries()) {
|
|
131
|
+
results[resultIndex] = reconciled[offset];
|
|
132
|
+
}
|
|
62
133
|
}
|
|
63
134
|
return results;
|
|
64
135
|
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import type { Comment } from "./types.js";
|
|
2
|
+
export interface RevisionProjectionIndex {
|
|
3
|
+
sourceLines: string[];
|
|
4
|
+
targetLines: string[];
|
|
5
|
+
lineMap: Map<number, number>;
|
|
6
|
+
}
|
|
7
|
+
export interface ProjectedAnchor {
|
|
8
|
+
line: number;
|
|
9
|
+
endLine: number;
|
|
10
|
+
startColumn?: number;
|
|
11
|
+
endColumn?: number;
|
|
12
|
+
text: string;
|
|
13
|
+
score: number;
|
|
14
|
+
exact: boolean;
|
|
15
|
+
contextSupport: number;
|
|
16
|
+
contextMargin: number;
|
|
17
|
+
reason: string;
|
|
18
|
+
}
|
|
19
|
+
export declare function createRevisionProjection(sourceLines: string[], targetLines: string[]): RevisionProjectionIndex;
|
|
20
|
+
export declare function projectCommentAnchor(comment: Comment, projection: RevisionProjectionIndex, threshold: number): ProjectedAnchor | undefined;
|
|
21
|
+
//# sourceMappingURL=revision-projection.d.ts.map
|
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
import { combinedScore, exactMatch } from "./fuzzy.js";
|
|
2
|
+
const CONTEXT_RADIUS = 8;
|
|
3
|
+
export function createRevisionProjection(sourceLines, targetLines) {
|
|
4
|
+
const sourceOccurrences = collectLineOccurrences(sourceLines);
|
|
5
|
+
const targetOccurrences = collectLineOccurrences(targetLines);
|
|
6
|
+
const lineMap = new Map();
|
|
7
|
+
for (const [text, sourceLineNumbers] of sourceOccurrences) {
|
|
8
|
+
const targetLineNumbers = targetOccurrences.get(text);
|
|
9
|
+
if (sourceLineNumbers.length === 1 && targetLineNumbers?.length === 1) {
|
|
10
|
+
lineMap.set(sourceLineNumbers[0], targetLineNumbers[0]);
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
return { sourceLines, targetLines, lineMap };
|
|
14
|
+
}
|
|
15
|
+
export function projectCommentAnchor(comment, projection, threshold) {
|
|
16
|
+
if (comment.line == null || !comment.selected_text)
|
|
17
|
+
return undefined;
|
|
18
|
+
const sourceText = extractText(projection.sourceLines, comment.line, comment.end_line, comment.start_column, comment.end_column);
|
|
19
|
+
if (sourceText !== comment.selected_text)
|
|
20
|
+
return undefined;
|
|
21
|
+
const exactCandidates = exactMatch(projection.targetLines, comment.selected_text);
|
|
22
|
+
if (exactCandidates.length === 1) {
|
|
23
|
+
const candidate = exactCandidates[0];
|
|
24
|
+
const contextSupport = countIndependentExactSupport(comment, candidate.line, projection);
|
|
25
|
+
if (contextSupport >= 2) {
|
|
26
|
+
return {
|
|
27
|
+
line: candidate.line,
|
|
28
|
+
endLine: candidate.endLine,
|
|
29
|
+
startColumn: candidate.startColumn,
|
|
30
|
+
endColumn: candidate.endColumn,
|
|
31
|
+
text: candidate.text,
|
|
32
|
+
score: 1,
|
|
33
|
+
exact: true,
|
|
34
|
+
contextSupport,
|
|
35
|
+
contextMargin: 1,
|
|
36
|
+
reason: "Source revision and neighboring line evidence confirm exact relocation.",
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
if (exactCandidates.length > 0)
|
|
41
|
+
return undefined;
|
|
42
|
+
const projected = projectLineFromContext(comment, projection);
|
|
43
|
+
if (projected == null)
|
|
44
|
+
return undefined;
|
|
45
|
+
const projectedLine = projected.line;
|
|
46
|
+
const lineSpan = (comment.end_line ?? comment.line) - comment.line;
|
|
47
|
+
const projectedEndLine = projectedLine + lineSpan;
|
|
48
|
+
if (projectedLine < 1
|
|
49
|
+
|| projectedEndLine >= projection.targetLines.length) {
|
|
50
|
+
return undefined;
|
|
51
|
+
}
|
|
52
|
+
const columns = projectColumns(comment, projection, projectedLine);
|
|
53
|
+
const targetText = extractText(projection.targetLines, projectedLine, projectedEndLine, columns.startColumn, columns.endColumn);
|
|
54
|
+
if (targetText == null)
|
|
55
|
+
return undefined;
|
|
56
|
+
const score = Math.min(1, combinedScore(comment.selected_text, targetText) + 0.1);
|
|
57
|
+
if (score < threshold)
|
|
58
|
+
return undefined;
|
|
59
|
+
return {
|
|
60
|
+
line: projectedLine,
|
|
61
|
+
endLine: projectedEndLine,
|
|
62
|
+
startColumn: columns.startColumn,
|
|
63
|
+
endColumn: columns.endColumn,
|
|
64
|
+
text: targetText,
|
|
65
|
+
score,
|
|
66
|
+
exact: targetText === comment.selected_text,
|
|
67
|
+
contextSupport: projected.support,
|
|
68
|
+
contextMargin: projected.margin,
|
|
69
|
+
reason: "Source revision context projects the edited anchor range.",
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
function collectLineOccurrences(lines) {
|
|
73
|
+
const occurrences = new Map();
|
|
74
|
+
for (let line = 1; line < lines.length; line += 1) {
|
|
75
|
+
const text = lines[line];
|
|
76
|
+
if (!text.trim())
|
|
77
|
+
continue;
|
|
78
|
+
const existing = occurrences.get(text);
|
|
79
|
+
if (existing) {
|
|
80
|
+
existing.push(line);
|
|
81
|
+
}
|
|
82
|
+
else {
|
|
83
|
+
occurrences.set(text, [line]);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
return occurrences;
|
|
87
|
+
}
|
|
88
|
+
function countIndependentExactSupport(comment, candidateLine, projection) {
|
|
89
|
+
const sourceEndLine = comment.end_line ?? comment.line;
|
|
90
|
+
const expectedShift = candidateLine - comment.line;
|
|
91
|
+
if (comment.start_column != null
|
|
92
|
+
|| comment.end_column != null) {
|
|
93
|
+
const mappedContainerLine = projection.lineMap.get(comment.line);
|
|
94
|
+
if (mappedContainerLine === candidateLine)
|
|
95
|
+
return 2;
|
|
96
|
+
}
|
|
97
|
+
let support = 0;
|
|
98
|
+
for (let distance = 1; distance <= CONTEXT_RADIUS; distance += 1) {
|
|
99
|
+
for (const sourceLine of [
|
|
100
|
+
comment.line - distance,
|
|
101
|
+
sourceEndLine + distance,
|
|
102
|
+
]) {
|
|
103
|
+
if (sourceLine < 1 || sourceLine >= projection.sourceLines.length) {
|
|
104
|
+
continue;
|
|
105
|
+
}
|
|
106
|
+
const targetLine = projection.lineMap.get(sourceLine);
|
|
107
|
+
if (targetLine != null && targetLine - sourceLine === expectedShift) {
|
|
108
|
+
support += 1;
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
return support;
|
|
113
|
+
}
|
|
114
|
+
function projectLineFromContext(comment, projection) {
|
|
115
|
+
const sourceLine = comment.line;
|
|
116
|
+
const sourceEndLine = comment.end_line ?? sourceLine;
|
|
117
|
+
const votes = new Map();
|
|
118
|
+
for (let distance = 1; distance <= CONTEXT_RADIUS; distance += 1) {
|
|
119
|
+
for (const contextLine of [
|
|
120
|
+
sourceLine - distance,
|
|
121
|
+
sourceEndLine + distance,
|
|
122
|
+
]) {
|
|
123
|
+
if (contextLine < 1 || contextLine >= projection.sourceLines.length) {
|
|
124
|
+
continue;
|
|
125
|
+
}
|
|
126
|
+
const targetLine = projection.lineMap.get(contextLine);
|
|
127
|
+
if (targetLine == null)
|
|
128
|
+
continue;
|
|
129
|
+
const shift = targetLine - contextLine;
|
|
130
|
+
const vote = votes.get(shift);
|
|
131
|
+
if (vote) {
|
|
132
|
+
vote.count += 1;
|
|
133
|
+
vote.nearest = Math.min(vote.nearest, distance);
|
|
134
|
+
}
|
|
135
|
+
else {
|
|
136
|
+
votes.set(shift, { count: 1, nearest: distance });
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
const ranked = [...votes.entries()].sort((left, right) => right[1].count - left[1].count
|
|
141
|
+
|| left[1].nearest - right[1].nearest
|
|
142
|
+
|| Math.abs(left[0]) - Math.abs(right[0]));
|
|
143
|
+
const best = ranked[0];
|
|
144
|
+
if (!best)
|
|
145
|
+
return undefined;
|
|
146
|
+
if (best[1].count < 2 && best[1].nearest > 3)
|
|
147
|
+
return undefined;
|
|
148
|
+
if (ranked[1]
|
|
149
|
+
&& ranked[1][1].count === best[1].count
|
|
150
|
+
&& ranked[1][1].nearest === best[1].nearest) {
|
|
151
|
+
return undefined;
|
|
152
|
+
}
|
|
153
|
+
const runnerUpCount = ranked[1]?.[1].count ?? 0;
|
|
154
|
+
return {
|
|
155
|
+
line: sourceLine + best[0],
|
|
156
|
+
support: best[1].count,
|
|
157
|
+
margin: (best[1].count - runnerUpCount) / best[1].count,
|
|
158
|
+
};
|
|
159
|
+
}
|
|
160
|
+
function projectColumns(comment, projection, targetLine) {
|
|
161
|
+
if (comment.line == null
|
|
162
|
+
|| (comment.end_line != null && comment.end_line !== comment.line)
|
|
163
|
+
|| comment.start_column == null
|
|
164
|
+
|| comment.end_column == null) {
|
|
165
|
+
return {
|
|
166
|
+
startColumn: comment.start_column,
|
|
167
|
+
endColumn: comment.end_column,
|
|
168
|
+
};
|
|
169
|
+
}
|
|
170
|
+
const sourceLine = projection.sourceLines[comment.line];
|
|
171
|
+
const targetLineText = projection.targetLines[targetLine];
|
|
172
|
+
const prefix = sourceLine.slice(0, comment.start_column);
|
|
173
|
+
const suffix = sourceLine.slice(comment.end_column);
|
|
174
|
+
const startColumn = targetLineText.startsWith(prefix)
|
|
175
|
+
? prefix.length
|
|
176
|
+
: Math.min(comment.start_column, targetLineText.length);
|
|
177
|
+
const endColumn = targetLineText.endsWith(suffix)
|
|
178
|
+
? targetLineText.length - suffix.length
|
|
179
|
+
: Math.min(targetLineText.length, startColumn + (comment.end_column - comment.start_column));
|
|
180
|
+
return { startColumn, endColumn };
|
|
181
|
+
}
|
|
182
|
+
function extractText(lines, line, endLine, startColumn, endColumn) {
|
|
183
|
+
const finalLine = endLine ?? line;
|
|
184
|
+
if (line < 1 || finalLine >= lines.length)
|
|
185
|
+
return null;
|
|
186
|
+
if (line === finalLine) {
|
|
187
|
+
const text = lines[line];
|
|
188
|
+
return startColumn != null && endColumn != null
|
|
189
|
+
? text.slice(startColumn, endColumn)
|
|
190
|
+
: text;
|
|
191
|
+
}
|
|
192
|
+
const result = [];
|
|
193
|
+
for (let current = line; current <= finalLine; current += 1) {
|
|
194
|
+
let text = lines[current];
|
|
195
|
+
if (current === line && startColumn != null)
|
|
196
|
+
text = text.slice(startColumn);
|
|
197
|
+
if (current === finalLine && endColumn != null)
|
|
198
|
+
text = text.slice(0, endColumn);
|
|
199
|
+
result.push(text);
|
|
200
|
+
}
|
|
201
|
+
return result.join("\n");
|
|
202
|
+
}
|
|
203
|
+
//# sourceMappingURL=revision-projection.js.map
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@mrsf/cli",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.7.0",
|
|
4
4
|
"description": "CLI and library for the Markdown Review Sidecar Format (MRSF)",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/index.js",
|
|
@@ -30,6 +30,10 @@
|
|
|
30
30
|
"test": "vitest run",
|
|
31
31
|
"test:watch": "vitest",
|
|
32
32
|
"lint": "tsc --noEmit",
|
|
33
|
+
"eval:reanchor": "tsc && node dist/evaluation/run-reanchor-eval.js",
|
|
34
|
+
"eval:reanchor:baseline": "tsc && node dist/evaluation/run-reanchor-eval.js --baseline ../evaluation/reanchor/baseline.json",
|
|
35
|
+
"eval:reanchor:generate": "tsc && node dist/evaluation/run-reanchor-generator.js",
|
|
36
|
+
"eval:reanchor:profile": "tsc && node dist/evaluation/run-reanchor-profile.js",
|
|
33
37
|
"prepublishOnly": "cp ../LICENSE . && npm run build && npm test",
|
|
34
38
|
"postpublish": "rm -f LICENSE"
|
|
35
39
|
},
|