@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.
Files changed (37) hide show
  1. package/README.md +10 -7
  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/evaluation/generate-reanchor-cases.d.ts +10 -0
  7. package/dist/evaluation/generate-reanchor-cases.js +301 -0
  8. package/dist/evaluation/reanchor-eval.d.ts +111 -0
  9. package/dist/evaluation/reanchor-eval.js +266 -0
  10. package/dist/evaluation/reanchor-gates.d.ts +29 -0
  11. package/dist/evaluation/reanchor-gates.js +47 -0
  12. package/dist/evaluation/run-reanchor-eval.d.ts +3 -0
  13. package/dist/evaluation/run-reanchor-eval.js +103 -0
  14. package/dist/evaluation/run-reanchor-generator.d.ts +3 -0
  15. package/dist/evaluation/run-reanchor-generator.js +63 -0
  16. package/dist/evaluation/run-reanchor-profile.d.ts +3 -0
  17. package/dist/evaluation/run-reanchor-profile.js +129 -0
  18. package/dist/index.cjs +1209 -34
  19. package/dist/index.cjs.map +4 -4
  20. package/dist/index.d.ts +12 -0
  21. package/dist/index.js +9 -0
  22. package/dist/lib/anchor-context.d.ts +46 -0
  23. package/dist/lib/anchor-context.js +474 -0
  24. package/dist/lib/confidence-calibration.d.ts +16 -0
  25. package/dist/lib/confidence-calibration.js +120 -0
  26. package/dist/lib/fuzzy.d.ts +14 -1
  27. package/dist/lib/fuzzy.js +140 -19
  28. package/dist/lib/git.d.ts +2 -0
  29. package/dist/lib/git.js +12 -0
  30. package/dist/lib/global-reconciliation.d.ts +11 -0
  31. package/dist/lib/global-reconciliation.js +145 -0
  32. package/dist/lib/reanchor-core.d.ts +7 -0
  33. package/dist/lib/reanchor-core.js +46 -4
  34. package/dist/lib/reanchor.js +82 -11
  35. package/dist/lib/revision-projection.d.ts +21 -0
  36. package/dist/lib/revision-projection.js +203 -0
  37. package/package.json +5 -1
@@ -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
- // Determine git context
36
- let diffHunks;
37
- let commitIsStale = false;
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 commentCommit = globalFrom ?? comment.commit;
47
- if (commentCommit && head && commentCommit !== head) {
48
- commitIsStale = true;
49
- // Get diff for this commit range
50
- const hunks = await getDiff(commentCommit, head, relPath, repoRoot);
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, { threshold, commitIsStale: false, proximityWindow }));
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.6.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
  },