@mrsf/cli 0.5.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 +1273 -37
- package/dist/browser.cjs.map +4 -4
- package/dist/browser.d.ts +18 -1
- package/dist/browser.js +14 -0
- package/dist/commands/reanchor.js +3 -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 +1307 -85
- 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/parser.d.ts +4 -19
- package/dist/lib/parser.js +4 -140
- package/dist/lib/reanchor-core.d.ts +20 -0
- package/dist/lib/reanchor-core.js +123 -20
- package/dist/lib/reanchor.js +85 -12
- package/dist/lib/revision-projection.d.ts +21 -0
- package/dist/lib/revision-projection.js +203 -0
- package/dist/lib/serialize.d.ts +37 -0
- package/dist/lib/serialize.js +170 -0
- package/dist/lib/types.d.ts +7 -0
- package/dist/lib/writer.d.ts +1 -8
- package/dist/lib/writer.js +5 -14
- package/package.json +5 -1
package/dist/lib/parser.js
CHANGED
|
@@ -3,7 +3,10 @@
|
|
|
3
3
|
*/
|
|
4
4
|
import { readFile } from "node:fs/promises";
|
|
5
5
|
import path from "node:path";
|
|
6
|
-
import
|
|
6
|
+
import { parseSidecarContent, parseSidecarContentLenient, } from "./serialize.js";
|
|
7
|
+
// Re-exported from the Node-free serialize module so existing
|
|
8
|
+
// `@mrsf/cli` import paths keep working.
|
|
9
|
+
export { parseSidecarContent, parseSidecarContentLenient, } from "./serialize.js";
|
|
7
10
|
/**
|
|
8
11
|
* Parse an MRSF sidecar file from disk.
|
|
9
12
|
*/
|
|
@@ -12,37 +15,6 @@ export async function parseSidecar(filePath) {
|
|
|
12
15
|
const content = await readFile(abs, "utf-8");
|
|
13
16
|
return parseSidecarContent(content, abs);
|
|
14
17
|
}
|
|
15
|
-
/**
|
|
16
|
-
* Parse MRSF sidecar content from a string.
|
|
17
|
-
* Detects JSON vs YAML based on content or optional filename hint.
|
|
18
|
-
*/
|
|
19
|
-
export function parseSidecarContent(content, filenameHint) {
|
|
20
|
-
const trimmed = content.trim();
|
|
21
|
-
let parsed;
|
|
22
|
-
// Detect JSON by content or filename
|
|
23
|
-
const isJson = trimmed.startsWith("{") ||
|
|
24
|
-
(filenameHint && filenameHint.endsWith(".review.json"));
|
|
25
|
-
if (isJson) {
|
|
26
|
-
try {
|
|
27
|
-
parsed = JSON.parse(trimmed);
|
|
28
|
-
}
|
|
29
|
-
catch (e) {
|
|
30
|
-
throw new Error(`Failed to parse JSON: ${e.message}`);
|
|
31
|
-
}
|
|
32
|
-
}
|
|
33
|
-
else {
|
|
34
|
-
try {
|
|
35
|
-
parsed = yaml.load(trimmed, { schema: yaml.JSON_SCHEMA });
|
|
36
|
-
}
|
|
37
|
-
catch (e) {
|
|
38
|
-
throw new Error(`Failed to parse YAML: ${e.message}`);
|
|
39
|
-
}
|
|
40
|
-
}
|
|
41
|
-
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
42
|
-
throw new Error("MRSF sidecar must be a YAML/JSON object");
|
|
43
|
-
}
|
|
44
|
-
return parsed;
|
|
45
|
-
}
|
|
46
18
|
/**
|
|
47
19
|
* Lenient parse: attempts to parse a sidecar file from disk without
|
|
48
20
|
* throwing. On complete failure, returns `{ doc: null, error }`.
|
|
@@ -61,114 +33,6 @@ export async function parseSidecarLenient(filePath) {
|
|
|
61
33
|
}
|
|
62
34
|
return parseSidecarContentLenient(content, abs);
|
|
63
35
|
}
|
|
64
|
-
/**
|
|
65
|
-
* Lenient parse from string content.
|
|
66
|
-
*/
|
|
67
|
-
export function parseSidecarContentLenient(content, filenameHint) {
|
|
68
|
-
const trimmed = content.trim();
|
|
69
|
-
if (!trimmed) {
|
|
70
|
-
return { doc: null, error: "File is empty" };
|
|
71
|
-
}
|
|
72
|
-
const isJson = trimmed.startsWith("{") ||
|
|
73
|
-
(filenameHint && filenameHint.endsWith(".review.json"));
|
|
74
|
-
// First, try a normal parse
|
|
75
|
-
let parsed;
|
|
76
|
-
try {
|
|
77
|
-
parsed = isJson ? JSON.parse(trimmed) : yaml.load(trimmed, { schema: yaml.JSON_SCHEMA });
|
|
78
|
-
}
|
|
79
|
-
catch (e) {
|
|
80
|
-
// Total parse failure — try to salvage what we can from YAML
|
|
81
|
-
if (!isJson) {
|
|
82
|
-
return salvageYaml(trimmed);
|
|
83
|
-
}
|
|
84
|
-
return { doc: null, error: `Failed to parse JSON: ${e.message}` };
|
|
85
|
-
}
|
|
86
|
-
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
87
|
-
return { doc: null, error: "MRSF sidecar must be a YAML/JSON object" };
|
|
88
|
-
}
|
|
89
|
-
const obj = parsed;
|
|
90
|
-
const doc = {
|
|
91
|
-
mrsf_version: typeof obj.mrsf_version === "string" ? obj.mrsf_version : "1.0",
|
|
92
|
-
document: typeof obj.document === "string" ? obj.document : "unknown",
|
|
93
|
-
comments: [],
|
|
94
|
-
};
|
|
95
|
-
if (!Array.isArray(obj.comments)) {
|
|
96
|
-
return {
|
|
97
|
-
doc,
|
|
98
|
-
error: "comments field is not an array — file may be corrupted",
|
|
99
|
-
};
|
|
100
|
-
}
|
|
101
|
-
// Validate each comment individually
|
|
102
|
-
const good = [];
|
|
103
|
-
const bad = [];
|
|
104
|
-
for (let i = 0; i < obj.comments.length; i++) {
|
|
105
|
-
const c = obj.comments[i];
|
|
106
|
-
if (c && typeof c === "object" && !Array.isArray(c) && typeof c.id === "string") {
|
|
107
|
-
good.push(c);
|
|
108
|
-
}
|
|
109
|
-
else {
|
|
110
|
-
bad.push(i);
|
|
111
|
-
}
|
|
112
|
-
}
|
|
113
|
-
doc.comments = good;
|
|
114
|
-
if (bad.length > 0) {
|
|
115
|
-
return {
|
|
116
|
-
doc,
|
|
117
|
-
error: `${bad.length} comment(s) at indices [${bad.join(", ")}] were malformed and skipped`,
|
|
118
|
-
partialComments: good,
|
|
119
|
-
};
|
|
120
|
-
}
|
|
121
|
-
return { doc };
|
|
122
|
-
}
|
|
123
|
-
/**
|
|
124
|
-
* Attempt to extract individual comment blocks from corrupted YAML by
|
|
125
|
-
* splitting on `- id:` patterns and parsing each block independently.
|
|
126
|
-
*/
|
|
127
|
-
function salvageYaml(content) {
|
|
128
|
-
const salvaged = [];
|
|
129
|
-
let mrsf_version = "1.0";
|
|
130
|
-
let document = "unknown";
|
|
131
|
-
// Try to extract top-level fields from the beginning
|
|
132
|
-
const versionMatch = content.match(/^mrsf_version:\s*["']?([^"'\n]+)/m);
|
|
133
|
-
if (versionMatch)
|
|
134
|
-
mrsf_version = versionMatch[1].trim();
|
|
135
|
-
const docMatch = content.match(/^document:\s*["']?([^"'\n]+)/m);
|
|
136
|
-
if (docMatch)
|
|
137
|
-
document = docMatch[1].trim();
|
|
138
|
-
// Split on comment block boundaries (- id: ...)
|
|
139
|
-
const blocks = content.split(/(?=^ - id:\s)/m);
|
|
140
|
-
for (const block of blocks) {
|
|
141
|
-
const trimmed = block.trim();
|
|
142
|
-
if (!trimmed.startsWith("- id:"))
|
|
143
|
-
continue;
|
|
144
|
-
// Wrap in a minimal YAML array context and try to parse
|
|
145
|
-
try {
|
|
146
|
-
const parsed = yaml.load(trimmed, { schema: yaml.JSON_SCHEMA });
|
|
147
|
-
if (Array.isArray(parsed) && parsed.length > 0) {
|
|
148
|
-
const c = parsed[0];
|
|
149
|
-
if (c && typeof c === "object" && typeof c.id === "string") {
|
|
150
|
-
salvaged.push(c);
|
|
151
|
-
}
|
|
152
|
-
}
|
|
153
|
-
else if (parsed && typeof parsed === "object" && typeof parsed.id === "string") {
|
|
154
|
-
salvaged.push(parsed);
|
|
155
|
-
}
|
|
156
|
-
}
|
|
157
|
-
catch {
|
|
158
|
-
// This block is unparseable — skip
|
|
159
|
-
}
|
|
160
|
-
}
|
|
161
|
-
const doc = {
|
|
162
|
-
mrsf_version,
|
|
163
|
-
document,
|
|
164
|
-
comments: salvaged,
|
|
165
|
-
};
|
|
166
|
-
return {
|
|
167
|
-
doc: salvaged.length > 0 ? doc : null,
|
|
168
|
-
error: `YAML parse failed. Salvaged ${salvaged.length} comment(s) from raw content.`,
|
|
169
|
-
partialComments: salvaged.length > 0 ? salvaged : undefined,
|
|
170
|
-
};
|
|
171
|
-
}
|
|
172
36
|
/**
|
|
173
37
|
* Read a Markdown document from disk and return its lines.
|
|
174
38
|
* Lines are 1-indexed in the returned array (index 0 is unused).
|
|
@@ -1,20 +1,40 @@
|
|
|
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;
|
|
7
|
+
/**
|
|
8
|
+
* Default proximity window (in lines) for the §7.4 step 1a relocation guard.
|
|
9
|
+
*
|
|
10
|
+
* A lone exact match of `selected_text` that lands farther than this many
|
|
11
|
+
* lines from the comment's original `line` — while the text at the original
|
|
12
|
+
* position has changed — is treated as an in-place edit rather than a
|
|
13
|
+
* confident relocation. See {@link isImplausibleExactRelocation}.
|
|
14
|
+
*/
|
|
15
|
+
export declare const DEFAULT_PROXIMITY_WINDOW = 5;
|
|
4
16
|
export declare function toReanchorLines(documentText: string): string[];
|
|
5
17
|
export declare function reanchorComment(comment: Comment, documentLines: string[], opts?: {
|
|
6
18
|
diffHunks?: DiffHunk[];
|
|
7
19
|
threshold?: number;
|
|
8
20
|
commitIsStale?: boolean;
|
|
21
|
+
proximityWindow?: number;
|
|
22
|
+
revisionProjection?: RevisionProjectionIndex;
|
|
23
|
+
anchorContext?: AnchorContextIndex;
|
|
24
|
+
fuzzySearchIndex?: FuzzySearchIndex;
|
|
25
|
+
getFuzzySearchIndex?: () => FuzzySearchIndex;
|
|
9
26
|
}): ReanchorResult;
|
|
10
27
|
export declare function reanchorDocumentLines(doc: MrsfDocument, documentLines: string[], opts?: {
|
|
11
28
|
threshold?: number;
|
|
29
|
+
proximityWindow?: number;
|
|
12
30
|
}): ReanchorResult[];
|
|
13
31
|
export declare function reanchorDocumentText(doc: MrsfDocument, documentText: string, opts?: {
|
|
14
32
|
threshold?: number;
|
|
33
|
+
proximityWindow?: number;
|
|
15
34
|
}): ReanchorResult[];
|
|
16
35
|
export declare function resolveAnchor(comment: Comment, documentText: string, opts?: {
|
|
17
36
|
threshold?: number;
|
|
37
|
+
proximityWindow?: number;
|
|
18
38
|
}): AnchorPosition;
|
|
19
39
|
export declare function applyReanchorResults(doc: MrsfDocument, results: ReanchorResult[], opts?: {
|
|
20
40
|
updateText?: boolean;
|
|
@@ -1,13 +1,33 @@
|
|
|
1
|
-
import { exactMatch, fuzzySearch, normalizedMatch, } from "./fuzzy.js";
|
|
1
|
+
import { createFuzzySearchIndex, exactMatch, fuzzySearch, fuzzySearchThresholds, normalizedMatch, } from "./fuzzy.js";
|
|
2
|
+
import { projectCommentAnchor, } from "./revision-projection.js";
|
|
3
|
+
import { resolveContextAnchor, } from "./anchor-context.js";
|
|
4
|
+
import { calibrateAnchorEvidence } from "./confidence-calibration.js";
|
|
2
5
|
export const HIGH_THRESHOLD = 0.8;
|
|
3
6
|
export const DEFAULT_THRESHOLD = 0.6;
|
|
7
|
+
/**
|
|
8
|
+
* Default proximity window (in lines) for the §7.4 step 1a relocation guard.
|
|
9
|
+
*
|
|
10
|
+
* A lone exact match of `selected_text` that lands farther than this many
|
|
11
|
+
* lines from the comment's original `line` — while the text at the original
|
|
12
|
+
* position has changed — is treated as an in-place edit rather than a
|
|
13
|
+
* confident relocation. See {@link isImplausibleExactRelocation}.
|
|
14
|
+
*/
|
|
15
|
+
export const DEFAULT_PROXIMITY_WINDOW = 5;
|
|
4
16
|
export function toReanchorLines(documentText) {
|
|
5
17
|
return ["", ...documentText.replace(/\r\n/g, "\n").split("\n")];
|
|
6
18
|
}
|
|
7
19
|
export function reanchorComment(comment, documentLines, opts = {}) {
|
|
8
20
|
const threshold = opts.threshold ?? DEFAULT_THRESHOLD;
|
|
21
|
+
const proximityWindow = opts.proximityWindow ?? DEFAULT_PROXIMITY_WINDOW;
|
|
9
22
|
const commentId = comment.id;
|
|
10
23
|
const selectedText = comment.selected_text;
|
|
24
|
+
let fuzzyCandidateSets;
|
|
25
|
+
let fuzzySearchIndex = opts.fuzzySearchIndex;
|
|
26
|
+
const getFuzzySearchIndex = () => {
|
|
27
|
+
fuzzySearchIndex ??= opts.getFuzzySearchIndex?.()
|
|
28
|
+
?? createFuzzySearchIndex(documentLines);
|
|
29
|
+
return fuzzySearchIndex;
|
|
30
|
+
};
|
|
11
31
|
if (!selectedText && comment.line == null) {
|
|
12
32
|
return {
|
|
13
33
|
commentId,
|
|
@@ -52,32 +72,78 @@ export function reanchorComment(comment, documentLines, opts = {}) {
|
|
|
52
72
|
}
|
|
53
73
|
}
|
|
54
74
|
}
|
|
75
|
+
const projected = selectedText && opts.revisionProjection
|
|
76
|
+
? projectCommentAnchor(comment, opts.revisionProjection, threshold)
|
|
77
|
+
: undefined;
|
|
78
|
+
if (selectedText && projected?.exact) {
|
|
79
|
+
const exactCalibration = calibrateAnchorEvidence(commentId, selectedText, projected);
|
|
80
|
+
if (exactCalibration)
|
|
81
|
+
return exactCalibration.result;
|
|
82
|
+
}
|
|
83
|
+
const contextual = selectedText && opts.anchorContext
|
|
84
|
+
? resolveContextAnchor(comment, opts.anchorContext)
|
|
85
|
+
: undefined;
|
|
86
|
+
if (selectedText && (projected || contextual)) {
|
|
87
|
+
const calibrated = calibrateAnchorEvidence(commentId, selectedText, projected, contextual);
|
|
88
|
+
if (calibrated)
|
|
89
|
+
return calibrated.result;
|
|
90
|
+
}
|
|
55
91
|
if (selectedText) {
|
|
56
92
|
const exactCandidates = exactMatch(documentLines, selectedText);
|
|
57
|
-
if (exactCandidates.length
|
|
58
|
-
const candidate = exactCandidates[0];
|
|
93
|
+
if (exactCandidates.length > 1 && comment.line == null) {
|
|
59
94
|
return {
|
|
60
95
|
commentId,
|
|
61
|
-
status: "
|
|
62
|
-
score: 1
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
newStartColumn: candidate.startColumn,
|
|
66
|
-
newEndColumn: candidate.endColumn,
|
|
67
|
-
reason: "Exact text match (unique).",
|
|
96
|
+
status: "ambiguous",
|
|
97
|
+
score: 1,
|
|
98
|
+
reason: `Ambiguous: ${exactCandidates.length} exact matches and no position `
|
|
99
|
+
+ "or source context to disambiguate them.",
|
|
68
100
|
};
|
|
69
101
|
}
|
|
70
|
-
|
|
71
|
-
|
|
102
|
+
// Pick the best exact candidate: the only one, or — when several remain —
|
|
103
|
+
// the one nearest to the original line (§7.4 step 1b).
|
|
104
|
+
let chosen;
|
|
105
|
+
let chosenReason = "";
|
|
106
|
+
if (exactCandidates.length === 1) {
|
|
107
|
+
chosen = exactCandidates[0];
|
|
108
|
+
chosenReason = "Exact text match (unique).";
|
|
109
|
+
}
|
|
110
|
+
else if (exactCandidates.length > 1 && comment.line != null) {
|
|
111
|
+
chosen = closestToLine(exactCandidates, comment.line);
|
|
112
|
+
chosenReason = `Exact text match (${exactCandidates.length} occurrences; chose nearest to original line ${comment.line}).`;
|
|
113
|
+
}
|
|
114
|
+
if (chosen) {
|
|
115
|
+
// §7.4 step 1a proximity guard: a lone/closest exact match that lands far
|
|
116
|
+
// from the original position — while the text at the original position no
|
|
117
|
+
// longer equals selected_text — most likely indicates an in-place edit of
|
|
118
|
+
// the anchored text, not a genuine relocation. Keep the comment at its
|
|
119
|
+
// original position and flag it for re-anchoring instead of teleporting it
|
|
120
|
+
// onto an unrelated identical token with full confidence.
|
|
121
|
+
if (isImplausibleExactRelocation(comment, chosen, documentLines, proximityWindow)) {
|
|
122
|
+
const textAtOrigin = extractText(documentLines, comment.line, comment.end_line, comment.start_column, comment.end_column);
|
|
123
|
+
return {
|
|
124
|
+
commentId,
|
|
125
|
+
status: "fuzzy",
|
|
126
|
+
score: 0.5,
|
|
127
|
+
newLine: comment.line,
|
|
128
|
+
newEndLine: comment.end_line,
|
|
129
|
+
newStartColumn: comment.start_column,
|
|
130
|
+
newEndColumn: comment.end_column,
|
|
131
|
+
anchoredText: textAtOrigin ?? undefined,
|
|
132
|
+
previousSelectedText: selectedText,
|
|
133
|
+
reason: `Lone exact match at line ${chosen.line} is beyond the proximity window ` +
|
|
134
|
+
`(±${proximityWindow}) of original line ${comment.line} and the text at the ` +
|
|
135
|
+
`original position changed; kept at original position, needs re-anchoring.`,
|
|
136
|
+
};
|
|
137
|
+
}
|
|
72
138
|
return {
|
|
73
139
|
commentId,
|
|
74
140
|
status: "anchored",
|
|
75
141
|
score: 1.0,
|
|
76
|
-
newLine:
|
|
77
|
-
newEndLine:
|
|
78
|
-
newStartColumn:
|
|
79
|
-
newEndColumn:
|
|
80
|
-
reason:
|
|
142
|
+
newLine: chosen.line,
|
|
143
|
+
newEndLine: chosen.endLine,
|
|
144
|
+
newStartColumn: chosen.startColumn,
|
|
145
|
+
newEndColumn: chosen.endColumn,
|
|
146
|
+
reason: chosenReason,
|
|
81
147
|
};
|
|
82
148
|
}
|
|
83
149
|
const normCandidates = normalizedMatch(documentLines, selectedText);
|
|
@@ -96,7 +162,8 @@ export function reanchorComment(comment, documentLines, opts = {}) {
|
|
|
96
162
|
reason: "Normalized whitespace match.",
|
|
97
163
|
};
|
|
98
164
|
}
|
|
99
|
-
|
|
165
|
+
fuzzyCandidateSets = fuzzySearchThresholds(documentLines, selectedText, [HIGH_THRESHOLD, threshold], comment.line, getFuzzySearchIndex());
|
|
166
|
+
const fuzzyCandidates = fuzzyCandidateSets.get(HIGH_THRESHOLD) ?? [];
|
|
100
167
|
if (fuzzyCandidates.length === 1 || (fuzzyCandidates.length > 0 && fuzzyCandidates[0].score >= HIGH_THRESHOLD)) {
|
|
101
168
|
const best = fuzzyCandidates.length === 1
|
|
102
169
|
? fuzzyCandidates[0]
|
|
@@ -151,7 +218,8 @@ export function reanchorComment(comment, documentLines, opts = {}) {
|
|
|
151
218
|
}
|
|
152
219
|
}
|
|
153
220
|
if (selectedText) {
|
|
154
|
-
const lowCandidates =
|
|
221
|
+
const lowCandidates = fuzzyCandidateSets?.get(threshold)
|
|
222
|
+
?? fuzzySearch(documentLines, selectedText, threshold, comment.line, getFuzzySearchIndex());
|
|
155
223
|
if (lowCandidates.length === 1) {
|
|
156
224
|
const candidate = lowCandidates[0];
|
|
157
225
|
return {
|
|
@@ -187,7 +255,12 @@ export function reanchorComment(comment, documentLines, opts = {}) {
|
|
|
187
255
|
};
|
|
188
256
|
}
|
|
189
257
|
export function reanchorDocumentLines(doc, documentLines, opts = {}) {
|
|
190
|
-
|
|
258
|
+
let fuzzySearchIndex;
|
|
259
|
+
const getFuzzySearchIndex = () => {
|
|
260
|
+
fuzzySearchIndex ??= createFuzzySearchIndex(documentLines);
|
|
261
|
+
return fuzzySearchIndex;
|
|
262
|
+
};
|
|
263
|
+
return doc.comments.map((comment) => reanchorComment(comment, documentLines, { ...opts, getFuzzySearchIndex }));
|
|
191
264
|
}
|
|
192
265
|
export function reanchorDocumentText(doc, documentText, opts = {}) {
|
|
193
266
|
return reanchorDocumentLines(doc, toReanchorLines(documentText), opts);
|
|
@@ -342,6 +415,36 @@ function extractText(lines, line, endLine, startColumn, endColumn) {
|
|
|
342
415
|
function closestToLine(candidates, targetLine) {
|
|
343
416
|
return candidates.reduce((best, candidate) => Math.abs(candidate.line - targetLine) < Math.abs(best.line - targetLine) ? candidate : best);
|
|
344
417
|
}
|
|
418
|
+
/**
|
|
419
|
+
* §7.4 step 1a relocation guard.
|
|
420
|
+
*
|
|
421
|
+
* Returns true when a chosen exact-match candidate is an *implausible*
|
|
422
|
+
* full-confidence relocation: the original `line` still exists in the document,
|
|
423
|
+
* the candidate is farther than `proximityWindow` lines away, and the text now
|
|
424
|
+
* at the original position no longer equals `selected_text`. This is the
|
|
425
|
+
* signature of an in-place edit of the anchored text (which removed the
|
|
426
|
+
* original occurrence) rather than a genuine move of the selection.
|
|
427
|
+
*
|
|
428
|
+
* When the original line no longer exists (the document shrank or the section
|
|
429
|
+
* was removed) or no positional anchor is available, relocation is treated as a
|
|
430
|
+
* legitimate §7.4 step 3 contextual re-anchor and this returns false.
|
|
431
|
+
*/
|
|
432
|
+
function isImplausibleExactRelocation(comment, candidate, lines, proximityWindow) {
|
|
433
|
+
if (comment.line == null)
|
|
434
|
+
return false;
|
|
435
|
+
// Original position must still exist to be a viable fallback anchor.
|
|
436
|
+
if (comment.line <= 0 || comment.line >= lines.length)
|
|
437
|
+
return false;
|
|
438
|
+
// A nearby match is plausibly the same (or an adjacent) edit region.
|
|
439
|
+
if (Math.abs(candidate.line - comment.line) <= proximityWindow)
|
|
440
|
+
return false;
|
|
441
|
+
// If the text at the original position still equals selected_text, the
|
|
442
|
+
// original occurrence is intact and relocation is not a teleport.
|
|
443
|
+
const textAtOrigin = extractText(lines, comment.line, comment.end_line, comment.start_column, comment.end_column);
|
|
444
|
+
if (textAtOrigin === comment.selected_text)
|
|
445
|
+
return false;
|
|
446
|
+
return true;
|
|
447
|
+
}
|
|
345
448
|
function getLineShift(diffHunks, line) {
|
|
346
449
|
let shift = 0;
|
|
347
450
|
let modified = false;
|
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";
|
|
@@ -31,9 +35,12 @@ export { applyReanchorResults, DEFAULT_THRESHOLD, HIGH_THRESHOLD, reanchorCommen
|
|
|
31
35
|
export async function reanchorDocument(doc, documentLines, opts = {}) {
|
|
32
36
|
const results = [];
|
|
33
37
|
const threshold = opts.threshold ?? DEFAULT_THRESHOLD;
|
|
34
|
-
|
|
35
|
-
let
|
|
36
|
-
|
|
38
|
+
const proximityWindow = opts.proximityWindow;
|
|
39
|
+
let fuzzySearchIndex;
|
|
40
|
+
const getFuzzySearchIndex = () => {
|
|
41
|
+
fuzzySearchIndex ??= createFuzzySearchIndex(documentLines);
|
|
42
|
+
return fuzzySearchIndex;
|
|
43
|
+
};
|
|
37
44
|
if (!opts.noGit && (await isGitAvailable())) {
|
|
38
45
|
const repoRoot = opts.repoRoot ?? (await findRepoRoot(opts.cwd));
|
|
39
46
|
if (repoRoot && opts.documentPath) {
|
|
@@ -41,27 +48,93 @@ export async function reanchorDocument(doc, documentLines, opts = {}) {
|
|
|
41
48
|
const head = await getCurrentCommit(repoRoot);
|
|
42
49
|
// Use a shared fromCommit for all comments, or per-comment
|
|
43
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();
|
|
44
56
|
for (const comment of doc.comments) {
|
|
45
|
-
const
|
|
46
|
-
if (
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
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
|
+
}
|
|
50
93
|
const result = reanchorComment(comment, documentLines, {
|
|
51
94
|
diffHunks: hunks,
|
|
52
95
|
threshold,
|
|
53
96
|
commitIsStale: true,
|
|
97
|
+
proximityWindow,
|
|
98
|
+
revisionProjection,
|
|
99
|
+
anchorContext,
|
|
100
|
+
getFuzzySearchIndex,
|
|
54
101
|
});
|
|
102
|
+
const resultIndex = results.length;
|
|
55
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
|
+
}
|
|
56
118
|
continue;
|
|
57
119
|
}
|
|
58
120
|
// non-stale or no commit
|
|
59
|
-
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
|
+
}
|
|
60
133
|
}
|
|
61
134
|
return results;
|
|
62
135
|
}
|
|
63
136
|
}
|
|
64
|
-
return reanchorDocumentLines(doc, documentLines, { threshold });
|
|
137
|
+
return reanchorDocumentLines(doc, documentLines, { threshold, proximityWindow });
|
|
65
138
|
}
|
|
66
139
|
/**
|
|
67
140
|
* High-level re-anchor for a single sidecar file path.
|
|
@@ -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
|