@mrsf/cli 0.4.3 → 0.6.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 +29 -0
- package/dist/browser.cjs +1046 -0
- package/dist/browser.cjs.map +7 -0
- package/dist/browser.d.ts +17 -1
- package/dist/browser.js +15 -0
- package/dist/commands/reanchor.js +3 -0
- package/dist/index.cjs +2184 -0
- package/dist/index.cjs.map +7 -0
- package/dist/index.d.ts +11 -1
- package/dist/index.js +10 -0
- package/dist/lib/comments.js +2 -2
- package/dist/lib/fuzzy.js +15 -152
- package/dist/lib/identity.d.ts +12 -0
- package/dist/lib/identity.js +23 -0
- package/dist/lib/parser.d.ts +4 -19
- package/dist/lib/parser.js +4 -140
- package/dist/lib/reanchor-core.d.ts +17 -1
- package/dist/lib/reanchor-core.js +141 -18
- package/dist/lib/reanchor.d.ts +1 -1
- package/dist/lib/reanchor.js +5 -3
- package/dist/lib/schema.d.ts +106 -0
- package/dist/lib/schema.js +3 -0
- package/dist/lib/serialize.d.ts +37 -0
- package/dist/lib/serialize.js +170 -0
- package/dist/lib/types.d.ts +25 -0
- package/dist/lib/validate-core.d.ts +6 -0
- package/dist/lib/validate-core.js +128 -0
- package/dist/lib/validator.js +4 -112
- package/dist/lib/writer.d.ts +1 -8
- package/dist/lib/writer.js +5 -14
- package/package.json +14 -6
package/dist/lib/fuzzy.js
CHANGED
|
@@ -110,37 +110,6 @@ export function normalizedMatch(lines, needle) {
|
|
|
110
110
|
function tokenize(text) {
|
|
111
111
|
return text.split(/\s+/).filter((t) => t.length > 0);
|
|
112
112
|
}
|
|
113
|
-
/**
|
|
114
|
-
* Build a frequency map for a token array. Used for fast multiset
|
|
115
|
-
* intersection during the fuzzySearch prefilter.
|
|
116
|
-
*/
|
|
117
|
-
function buildTokenFreq(tokens) {
|
|
118
|
-
const freq = new Map();
|
|
119
|
-
for (const tok of tokens) {
|
|
120
|
-
freq.set(tok, (freq.get(tok) ?? 0) + 1);
|
|
121
|
-
}
|
|
122
|
-
return freq;
|
|
123
|
-
}
|
|
124
|
-
/**
|
|
125
|
-
* Multiset intersection size: sum over needle tokens of
|
|
126
|
-
* min(needleFreq[token], windowFreq[token]). This is an upper bound on
|
|
127
|
-
* the longest common subsequence length, so it lets us cheaply skip
|
|
128
|
-
* windows that cannot possibly reach the score threshold.
|
|
129
|
-
*/
|
|
130
|
-
function multisetIntersection(needleFreq, windowFreq) {
|
|
131
|
-
let common = 0;
|
|
132
|
-
// Iterate the smaller map for speed.
|
|
133
|
-
const [smaller, larger] = needleFreq.size <= windowFreq.size
|
|
134
|
-
? [needleFreq, windowFreq]
|
|
135
|
-
: [windowFreq, needleFreq];
|
|
136
|
-
for (const [tok, cnt] of smaller) {
|
|
137
|
-
const other = larger.get(tok);
|
|
138
|
-
if (other !== undefined) {
|
|
139
|
-
common += cnt < other ? cnt : other;
|
|
140
|
-
}
|
|
141
|
-
}
|
|
142
|
-
return common;
|
|
143
|
-
}
|
|
144
113
|
/**
|
|
145
114
|
* Longest Common Subsequence length of two token arrays.
|
|
146
115
|
*/
|
|
@@ -230,144 +199,38 @@ export function fuzzySearch(lines, needle, threshold = 0.6, hintLine) {
|
|
|
230
199
|
const needleLines = needle.split("\n");
|
|
231
200
|
const needleLineCount = needleLines.length;
|
|
232
201
|
const candidates = [];
|
|
233
|
-
// Pre-tokenize: do this once per call instead of per window. Per-line
|
|
234
|
-
// tokenization gives identical results to tokenize(lines.join("\n")) since
|
|
235
|
-
// tokenize splits on /\s+/ and filters empties.
|
|
236
|
-
const lineTokens = new Array(lines.length);
|
|
237
|
-
const lineTokenLengths = new Array(lines.length);
|
|
238
|
-
for (let i = 0; i < lines.length; i++) {
|
|
239
|
-
const toks = lines[i] ? tokenize(lines[i]) : [];
|
|
240
|
-
lineTokens[i] = toks;
|
|
241
|
-
lineTokenLengths[i] = toks.length;
|
|
242
|
-
}
|
|
243
|
-
const needleTokens = tokenize(needle);
|
|
244
|
-
const needleTokenCount = needleTokens.length;
|
|
245
|
-
const needleFreq = buildTokenFreq(needleTokens);
|
|
246
|
-
// The combined score is 0.6 * tScore + 0.4 * lScore. Since lScore <= 1.0,
|
|
247
|
-
// a window can only reach `threshold` if tScore >= (threshold - 0.4) / 0.6.
|
|
248
|
-
// Use this as a hard prefilter before doing the expensive LCS / Levenshtein.
|
|
249
|
-
const minTScore = Math.max(0, (threshold - 0.4) / 0.6);
|
|
250
202
|
// Window sizes: ±30% of original line count, minimum 1
|
|
251
203
|
const minWindow = Math.max(1, Math.floor(needleLineCount * 0.7));
|
|
252
204
|
const maxWindow = Math.min(lines.length - 1, Math.ceil(needleLineCount * 1.3) + 1);
|
|
253
|
-
// Track total token count per window using a rolling sum so we don't
|
|
254
|
-
// re-walk the line-token arrays for every (winSize, startLine) pair.
|
|
255
205
|
for (let winSize = minWindow; winSize <= maxWindow; winSize++) {
|
|
256
|
-
if (winSize < 1)
|
|
257
|
-
continue;
|
|
258
|
-
if (winSize > lines.length - 1)
|
|
259
|
-
continue;
|
|
260
|
-
// Initial window token sum for startLine = 1.
|
|
261
|
-
let runningTokenCount = 0;
|
|
262
|
-
for (let i = 1; i < 1 + winSize && i < lines.length; i++) {
|
|
263
|
-
runningTokenCount += lineTokenLengths[i];
|
|
264
|
-
}
|
|
265
206
|
for (let startLine = 1; startLine + winSize - 1 < lines.length; startLine++) {
|
|
266
|
-
const
|
|
267
|
-
const
|
|
268
|
-
|
|
269
|
-
if (
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
for (let ti = 0; ti < arr.length; ti++) {
|
|
279
|
-
const tok = arr[ti];
|
|
280
|
-
windowFreq.set(tok, (windowFreq.get(tok) ?? 0) + 1);
|
|
281
|
-
}
|
|
282
|
-
}
|
|
283
|
-
// Multiset intersection is an upper bound on LCS length. If we
|
|
284
|
-
// can't reach minTScore even with a perfect LCS, skip the window.
|
|
285
|
-
const common = multisetIntersection(needleFreq, windowFreq);
|
|
286
|
-
const tScoreUpperBound = common / Math.max(needleTokenCount, M);
|
|
287
|
-
if (tScoreUpperBound >= minTScore) {
|
|
288
|
-
// Build the flat window token array only when we actually need
|
|
289
|
-
// to compute LCS.
|
|
290
|
-
const windowTokens = new Array(M);
|
|
291
|
-
let wi = 0;
|
|
292
|
-
for (let li = startLine; li < endLineExclusive; li++) {
|
|
293
|
-
const arr = lineTokens[li];
|
|
294
|
-
for (let ti = 0; ti < arr.length; ti++) {
|
|
295
|
-
windowTokens[wi++] = arr[ti];
|
|
296
|
-
}
|
|
297
|
-
}
|
|
298
|
-
const lcs = lcsLength(needleTokens, windowTokens);
|
|
299
|
-
const tScore = lcs / Math.max(needleTokenCount, M);
|
|
300
|
-
if (tScore >= minTScore) {
|
|
301
|
-
// Defer expensive string join + Levenshtein until we know the
|
|
302
|
-
// tScore could plausibly reach threshold.
|
|
303
|
-
const windowLines = lines.slice(startLine, endLineExclusive);
|
|
304
|
-
const windowText = windowLines.join("\n");
|
|
305
|
-
let lScore;
|
|
306
|
-
if (needle.length < 500 && windowText.length < 500) {
|
|
307
|
-
lScore = levenshteinScore(needle, windowText);
|
|
308
|
-
}
|
|
309
|
-
else {
|
|
310
|
-
lScore = tScore; // fall back to token score only
|
|
311
|
-
}
|
|
312
|
-
const score = tScore * 0.6 + lScore * 0.4;
|
|
313
|
-
if (score >= threshold) {
|
|
314
|
-
candidates.push({
|
|
315
|
-
text: windowText,
|
|
316
|
-
line: startLine,
|
|
317
|
-
endLine: startLine + winSize - 1,
|
|
318
|
-
startColumn: 0,
|
|
319
|
-
endColumn: windowLines[windowLines.length - 1].length,
|
|
320
|
-
score,
|
|
321
|
-
});
|
|
322
|
-
}
|
|
323
|
-
}
|
|
324
|
-
}
|
|
325
|
-
}
|
|
326
|
-
// Slide window by one: drop startLine token count, add endLine token count.
|
|
327
|
-
const nextStart = startLine + 1;
|
|
328
|
-
const nextEndExclusive = nextStart + winSize;
|
|
329
|
-
if (nextEndExclusive <= lines.length) {
|
|
330
|
-
runningTokenCount += lineTokenLengths[endLineExclusive] - lineTokenLengths[startLine];
|
|
207
|
+
const windowLines = lines.slice(startLine, startLine + winSize);
|
|
208
|
+
const windowText = windowLines.join("\n");
|
|
209
|
+
const score = combinedScore(needle, windowText);
|
|
210
|
+
if (score >= threshold) {
|
|
211
|
+
candidates.push({
|
|
212
|
+
text: windowText,
|
|
213
|
+
line: startLine,
|
|
214
|
+
endLine: startLine + winSize - 1,
|
|
215
|
+
startColumn: 0,
|
|
216
|
+
endColumn: windowLines[windowLines.length - 1].length,
|
|
217
|
+
score,
|
|
218
|
+
});
|
|
331
219
|
}
|
|
332
220
|
}
|
|
333
221
|
}
|
|
334
|
-
// For single-line needles, also try substring matching within each line
|
|
335
|
-
// Prefilter each line by token overlap before running the inner sliding
|
|
336
|
-
// window — long lines without enough needle-token coverage are skipped
|
|
337
|
-
// wholesale, since no substring of such a line can exceed its parent
|
|
338
|
-
// line's token-overlap with the needle.
|
|
222
|
+
// For single-line needles, also try substring matching within each line
|
|
339
223
|
if (needleLineCount === 1 && needle.length < 200) {
|
|
340
|
-
// The minimum number of needle tokens that must appear in a line for any
|
|
341
|
-
// substring of that line to plausibly reach minTScore. tScore =
|
|
342
|
-
// lcs / max(needleN, subN), so lcs >= minTScore * needleN is required,
|
|
343
|
-
// and lcs <= number of common tokens.
|
|
344
|
-
const requiredLineCommon = Math.max(0, Math.ceil(needleTokenCount * minTScore));
|
|
345
224
|
for (let lineNum = 1; lineNum < lines.length; lineNum++) {
|
|
346
225
|
const line = lines[lineNum];
|
|
347
226
|
if (!line)
|
|
348
227
|
continue;
|
|
349
|
-
|
|
350
|
-
const lineFreq = buildTokenFreq(lineTokens[lineNum]);
|
|
351
|
-
const lineCommon = multisetIntersection(needleFreq, lineFreq);
|
|
352
|
-
if (lineCommon < requiredLineCommon)
|
|
353
|
-
continue;
|
|
354
|
-
}
|
|
355
|
-
// Sliding window within line. Step by 1 by default for accuracy, but
|
|
356
|
-
// for very long lines and long needles, we coarsen the column step to
|
|
357
|
-
// avoid quadratic-in-line-length behaviour. The natural `len = winLen`
|
|
358
|
-
// is always swept densely so we don't miss the most likely match.
|
|
228
|
+
// Sliding window within line
|
|
359
229
|
const winLen = needle.length;
|
|
360
230
|
const minWinLen = Math.max(3, Math.floor(winLen * 0.7));
|
|
361
231
|
const maxWinLen = Math.min(line.length, Math.ceil(winLen * 1.3));
|
|
362
232
|
for (let len = minWinLen; len <= maxWinLen; len++) {
|
|
363
|
-
|
|
364
|
-
// sub-window is no longer the natural needle length. This trades a
|
|
365
|
-
// tiny bit of recall for an order-of-magnitude speedup on long
|
|
366
|
-
// lines with many candidate offsets.
|
|
367
|
-
const colStep = len === winLen
|
|
368
|
-
? 1
|
|
369
|
-
: Math.max(1, Math.floor(winLen / 16));
|
|
370
|
-
for (let col = 0; col + len <= line.length; col += colStep) {
|
|
233
|
+
for (let col = 0; col + len <= line.length; col++) {
|
|
371
234
|
const sub = line.substring(col, col + len);
|
|
372
235
|
const score = combinedScore(needle, sub);
|
|
373
236
|
if (score >= threshold) {
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
export interface ParsedAuthor {
|
|
2
|
+
name: string;
|
|
3
|
+
handle?: string;
|
|
4
|
+
}
|
|
5
|
+
export declare function formatAuthor(name: string, handle?: string): string;
|
|
6
|
+
export declare function parseAuthor(author: string): ParsedAuthor;
|
|
7
|
+
/**
|
|
8
|
+
* Create a collision-resistant MRSF comment id.
|
|
9
|
+
* ULID is also permitted by the spec; UUIDv4 is the default for this package.
|
|
10
|
+
*/
|
|
11
|
+
export declare function newCommentId(): string;
|
|
12
|
+
//# sourceMappingURL=identity.d.ts.map
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { v4 as uuidv4 } from "uuid";
|
|
2
|
+
export function formatAuthor(name, handle) {
|
|
3
|
+
const trimmedName = name.trim();
|
|
4
|
+
const trimmedHandle = handle?.trim();
|
|
5
|
+
return trimmedHandle ? `${trimmedName} (${trimmedHandle})` : trimmedName;
|
|
6
|
+
}
|
|
7
|
+
export function parseAuthor(author) {
|
|
8
|
+
const trimmed = author.trim();
|
|
9
|
+
const match = /^(.*?)\s*\(([^()]*)\)\s*$/.exec(trimmed);
|
|
10
|
+
if (!match)
|
|
11
|
+
return { name: trimmed };
|
|
12
|
+
const name = match[1].trim();
|
|
13
|
+
const handle = match[2].trim();
|
|
14
|
+
return handle ? { name, handle } : { name };
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* Create a collision-resistant MRSF comment id.
|
|
18
|
+
* ULID is also permitted by the spec; UUIDv4 is the default for this package.
|
|
19
|
+
*/
|
|
20
|
+
export function newCommentId() {
|
|
21
|
+
return uuidv4();
|
|
22
|
+
}
|
|
23
|
+
//# sourceMappingURL=identity.js.map
|
package/dist/lib/parser.d.ts
CHANGED
|
@@ -1,25 +1,14 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* MRSF Parser — load and parse MRSF sidecar files (YAML or JSON).
|
|
3
3
|
*/
|
|
4
|
-
import type { MrsfDocument
|
|
5
|
-
|
|
6
|
-
export
|
|
7
|
-
|
|
8
|
-
doc: MrsfDocument | null;
|
|
9
|
-
/** If parsing failed or produced warnings, the error message. */
|
|
10
|
-
error?: string;
|
|
11
|
-
/** Comments that could be salvaged from a partially-corrupted sidecar. */
|
|
12
|
-
partialComments?: Comment[];
|
|
13
|
-
}
|
|
4
|
+
import type { MrsfDocument } from "./types.js";
|
|
5
|
+
import { type LenientParseResult } from "./serialize.js";
|
|
6
|
+
export { parseSidecarContent, parseSidecarContentLenient, } from "./serialize.js";
|
|
7
|
+
export type { LenientParseResult } from "./serialize.js";
|
|
14
8
|
/**
|
|
15
9
|
* Parse an MRSF sidecar file from disk.
|
|
16
10
|
*/
|
|
17
11
|
export declare function parseSidecar(filePath: string): Promise<MrsfDocument>;
|
|
18
|
-
/**
|
|
19
|
-
* Parse MRSF sidecar content from a string.
|
|
20
|
-
* Detects JSON vs YAML based on content or optional filename hint.
|
|
21
|
-
*/
|
|
22
|
-
export declare function parseSidecarContent(content: string, filenameHint?: string): MrsfDocument;
|
|
23
12
|
/**
|
|
24
13
|
* Lenient parse: attempts to parse a sidecar file from disk without
|
|
25
14
|
* throwing. On complete failure, returns `{ doc: null, error }`.
|
|
@@ -28,10 +17,6 @@ export declare function parseSidecarContent(content: string, filenameHint?: stri
|
|
|
28
17
|
* salvage individual well-formed comments.
|
|
29
18
|
*/
|
|
30
19
|
export declare function parseSidecarLenient(filePath: string): Promise<LenientParseResult>;
|
|
31
|
-
/**
|
|
32
|
-
* Lenient parse from string content.
|
|
33
|
-
*/
|
|
34
|
-
export declare function parseSidecarContentLenient(content: string, filenameHint?: string): LenientParseResult;
|
|
35
20
|
/**
|
|
36
21
|
* Read a Markdown document from disk and return its lines.
|
|
37
22
|
* Lines are 1-indexed in the returned array (index 0 is unused).
|
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,18 +1,34 @@
|
|
|
1
|
-
import type { Comment, DiffHunk, MrsfDocument, ReanchorResult } from "./types.js";
|
|
1
|
+
import type { AnchorPosition, Comment, DiffHunk, MrsfDocument, ReanchorResult } from "./types.js";
|
|
2
2
|
export declare const HIGH_THRESHOLD = 0.8;
|
|
3
3
|
export declare const DEFAULT_THRESHOLD = 0.6;
|
|
4
|
+
/**
|
|
5
|
+
* Default proximity window (in lines) for the §7.4 step 1a relocation guard.
|
|
6
|
+
*
|
|
7
|
+
* A lone exact match of `selected_text` that lands farther than this many
|
|
8
|
+
* lines from the comment's original `line` — while the text at the original
|
|
9
|
+
* position has changed — is treated as an in-place edit rather than a
|
|
10
|
+
* confident relocation. See {@link isImplausibleExactRelocation}.
|
|
11
|
+
*/
|
|
12
|
+
export declare const DEFAULT_PROXIMITY_WINDOW = 5;
|
|
4
13
|
export declare function toReanchorLines(documentText: string): string[];
|
|
5
14
|
export declare function reanchorComment(comment: Comment, documentLines: string[], opts?: {
|
|
6
15
|
diffHunks?: DiffHunk[];
|
|
7
16
|
threshold?: number;
|
|
8
17
|
commitIsStale?: boolean;
|
|
18
|
+
proximityWindow?: number;
|
|
9
19
|
}): ReanchorResult;
|
|
10
20
|
export declare function reanchorDocumentLines(doc: MrsfDocument, documentLines: string[], opts?: {
|
|
11
21
|
threshold?: number;
|
|
22
|
+
proximityWindow?: number;
|
|
12
23
|
}): ReanchorResult[];
|
|
13
24
|
export declare function reanchorDocumentText(doc: MrsfDocument, documentText: string, opts?: {
|
|
14
25
|
threshold?: number;
|
|
26
|
+
proximityWindow?: number;
|
|
15
27
|
}): ReanchorResult[];
|
|
28
|
+
export declare function resolveAnchor(comment: Comment, documentText: string, opts?: {
|
|
29
|
+
threshold?: number;
|
|
30
|
+
proximityWindow?: number;
|
|
31
|
+
}): AnchorPosition;
|
|
16
32
|
export declare function applyReanchorResults(doc: MrsfDocument, results: ReanchorResult[], opts?: {
|
|
17
33
|
updateText?: boolean;
|
|
18
34
|
force?: boolean;
|
|
@@ -1,11 +1,21 @@
|
|
|
1
1
|
import { exactMatch, fuzzySearch, normalizedMatch, } from "./fuzzy.js";
|
|
2
2
|
export const HIGH_THRESHOLD = 0.8;
|
|
3
3
|
export const DEFAULT_THRESHOLD = 0.6;
|
|
4
|
+
/**
|
|
5
|
+
* Default proximity window (in lines) for the §7.4 step 1a relocation guard.
|
|
6
|
+
*
|
|
7
|
+
* A lone exact match of `selected_text` that lands farther than this many
|
|
8
|
+
* lines from the comment's original `line` — while the text at the original
|
|
9
|
+
* position has changed — is treated as an in-place edit rather than a
|
|
10
|
+
* confident relocation. See {@link isImplausibleExactRelocation}.
|
|
11
|
+
*/
|
|
12
|
+
export const DEFAULT_PROXIMITY_WINDOW = 5;
|
|
4
13
|
export function toReanchorLines(documentText) {
|
|
5
14
|
return ["", ...documentText.replace(/\r\n/g, "\n").split("\n")];
|
|
6
15
|
}
|
|
7
16
|
export function reanchorComment(comment, documentLines, opts = {}) {
|
|
8
17
|
const threshold = opts.threshold ?? DEFAULT_THRESHOLD;
|
|
18
|
+
const proximityWindow = opts.proximityWindow ?? DEFAULT_PROXIMITY_WINDOW;
|
|
9
19
|
const commentId = comment.id;
|
|
10
20
|
const selectedText = comment.selected_text;
|
|
11
21
|
if (!selectedText && comment.line == null) {
|
|
@@ -54,30 +64,51 @@ export function reanchorComment(comment, documentLines, opts = {}) {
|
|
|
54
64
|
}
|
|
55
65
|
if (selectedText) {
|
|
56
66
|
const exactCandidates = exactMatch(documentLines, selectedText);
|
|
67
|
+
// Pick the best exact candidate: the only one, or — when several remain —
|
|
68
|
+
// the one nearest to the original line (§7.4 step 1b).
|
|
69
|
+
let chosen;
|
|
70
|
+
let chosenReason = "";
|
|
57
71
|
if (exactCandidates.length === 1) {
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
newEndLine: candidate.endLine,
|
|
65
|
-
newStartColumn: candidate.startColumn,
|
|
66
|
-
newEndColumn: candidate.endColumn,
|
|
67
|
-
reason: "Exact text match (unique).",
|
|
68
|
-
};
|
|
72
|
+
chosen = exactCandidates[0];
|
|
73
|
+
chosenReason = "Exact text match (unique).";
|
|
74
|
+
}
|
|
75
|
+
else if (exactCandidates.length > 1 && comment.line != null) {
|
|
76
|
+
chosen = closestToLine(exactCandidates, comment.line);
|
|
77
|
+
chosenReason = `Exact text match (${exactCandidates.length} occurrences; chose nearest to original line ${comment.line}).`;
|
|
69
78
|
}
|
|
70
|
-
if (
|
|
71
|
-
|
|
79
|
+
if (chosen) {
|
|
80
|
+
// §7.4 step 1a proximity guard: a lone/closest exact match that lands far
|
|
81
|
+
// from the original position — while the text at the original position no
|
|
82
|
+
// longer equals selected_text — most likely indicates an in-place edit of
|
|
83
|
+
// the anchored text, not a genuine relocation. Keep the comment at its
|
|
84
|
+
// original position and flag it for re-anchoring instead of teleporting it
|
|
85
|
+
// onto an unrelated identical token with full confidence.
|
|
86
|
+
if (isImplausibleExactRelocation(comment, chosen, documentLines, proximityWindow)) {
|
|
87
|
+
const textAtOrigin = extractText(documentLines, comment.line, comment.end_line, comment.start_column, comment.end_column);
|
|
88
|
+
return {
|
|
89
|
+
commentId,
|
|
90
|
+
status: "fuzzy",
|
|
91
|
+
score: 0.5,
|
|
92
|
+
newLine: comment.line,
|
|
93
|
+
newEndLine: comment.end_line,
|
|
94
|
+
newStartColumn: comment.start_column,
|
|
95
|
+
newEndColumn: comment.end_column,
|
|
96
|
+
anchoredText: textAtOrigin ?? undefined,
|
|
97
|
+
previousSelectedText: selectedText,
|
|
98
|
+
reason: `Lone exact match at line ${chosen.line} is beyond the proximity window ` +
|
|
99
|
+
`(±${proximityWindow}) of original line ${comment.line} and the text at the ` +
|
|
100
|
+
`original position changed; kept at original position, needs re-anchoring.`,
|
|
101
|
+
};
|
|
102
|
+
}
|
|
72
103
|
return {
|
|
73
104
|
commentId,
|
|
74
105
|
status: "anchored",
|
|
75
106
|
score: 1.0,
|
|
76
|
-
newLine:
|
|
77
|
-
newEndLine:
|
|
78
|
-
newStartColumn:
|
|
79
|
-
newEndColumn:
|
|
80
|
-
reason:
|
|
107
|
+
newLine: chosen.line,
|
|
108
|
+
newEndLine: chosen.endLine,
|
|
109
|
+
newStartColumn: chosen.startColumn,
|
|
110
|
+
newEndColumn: chosen.endColumn,
|
|
111
|
+
reason: chosenReason,
|
|
81
112
|
};
|
|
82
113
|
}
|
|
83
114
|
const normCandidates = normalizedMatch(documentLines, selectedText);
|
|
@@ -192,6 +223,68 @@ export function reanchorDocumentLines(doc, documentLines, opts = {}) {
|
|
|
192
223
|
export function reanchorDocumentText(doc, documentText, opts = {}) {
|
|
193
224
|
return reanchorDocumentLines(doc, toReanchorLines(documentText), opts);
|
|
194
225
|
}
|
|
226
|
+
export function resolveAnchor(comment, documentText, opts = {}) {
|
|
227
|
+
const normalizedText = documentText.replace(/\r\n/g, "\n");
|
|
228
|
+
const documentLines = toReanchorLines(documentText);
|
|
229
|
+
const result = reanchorComment(comment, documentLines, opts);
|
|
230
|
+
if (result.status === "orphaned") {
|
|
231
|
+
return {
|
|
232
|
+
status: "orphaned",
|
|
233
|
+
score: result.score,
|
|
234
|
+
reason: result.reason,
|
|
235
|
+
};
|
|
236
|
+
}
|
|
237
|
+
const line = result.newLine ?? comment.line;
|
|
238
|
+
if (line == null) {
|
|
239
|
+
return {
|
|
240
|
+
status: result.status,
|
|
241
|
+
score: result.score,
|
|
242
|
+
reason: result.reason,
|
|
243
|
+
};
|
|
244
|
+
}
|
|
245
|
+
const rawLines = normalizedText.split("\n");
|
|
246
|
+
const lineStarts = computeLineStarts(rawLines);
|
|
247
|
+
const endLine = result.newEndLine ?? comment.end_line ?? line;
|
|
248
|
+
const startColumn = result.newStartColumn ?? comment.start_column ?? 0;
|
|
249
|
+
const endColumn = result.newEndColumn ?? comment.end_column;
|
|
250
|
+
const from = offsetFor(lineStarts, rawLines, line, startColumn);
|
|
251
|
+
const selectedText = comment.selected_text?.replace(/\r\n/g, "\n");
|
|
252
|
+
const to = endColumn != null
|
|
253
|
+
? offsetFor(lineStarts, rawLines, endLine, endColumn)
|
|
254
|
+
: selectedText
|
|
255
|
+
? from + selectedText.length
|
|
256
|
+
: offsetFor(lineStarts, rawLines, endLine, rawLines[endLine - 1]?.length ?? 0);
|
|
257
|
+
return {
|
|
258
|
+
status: result.status,
|
|
259
|
+
score: result.score,
|
|
260
|
+
from,
|
|
261
|
+
to,
|
|
262
|
+
line,
|
|
263
|
+
endLine,
|
|
264
|
+
startColumn,
|
|
265
|
+
endColumn: endColumn ?? columnForOffset(lineStarts, rawLines, endLine, to),
|
|
266
|
+
reason: result.reason,
|
|
267
|
+
};
|
|
268
|
+
}
|
|
269
|
+
function computeLineStarts(lines) {
|
|
270
|
+
const starts = [0];
|
|
271
|
+
let offset = 0;
|
|
272
|
+
for (const line of lines) {
|
|
273
|
+
starts.push(offset);
|
|
274
|
+
offset += line.length + 1;
|
|
275
|
+
}
|
|
276
|
+
return starts;
|
|
277
|
+
}
|
|
278
|
+
function offsetFor(lineStarts, lines, line, column) {
|
|
279
|
+
const start = lineStarts[line] ?? 0;
|
|
280
|
+
const maxColumn = lines[line - 1]?.length ?? 0;
|
|
281
|
+
return start + Math.max(0, Math.min(column, maxColumn));
|
|
282
|
+
}
|
|
283
|
+
function columnForOffset(lineStarts, lines, line, offset) {
|
|
284
|
+
const start = lineStarts[line] ?? 0;
|
|
285
|
+
const maxColumn = lines[line - 1]?.length ?? 0;
|
|
286
|
+
return Math.max(0, Math.min(offset - start, maxColumn));
|
|
287
|
+
}
|
|
195
288
|
export function applyReanchorResults(doc, results, opts = {}) {
|
|
196
289
|
let changed = 0;
|
|
197
290
|
const resultMap = new Map(results.map((result) => [result.commentId, result]));
|
|
@@ -280,6 +373,36 @@ function extractText(lines, line, endLine, startColumn, endColumn) {
|
|
|
280
373
|
function closestToLine(candidates, targetLine) {
|
|
281
374
|
return candidates.reduce((best, candidate) => Math.abs(candidate.line - targetLine) < Math.abs(best.line - targetLine) ? candidate : best);
|
|
282
375
|
}
|
|
376
|
+
/**
|
|
377
|
+
* §7.4 step 1a relocation guard.
|
|
378
|
+
*
|
|
379
|
+
* Returns true when a chosen exact-match candidate is an *implausible*
|
|
380
|
+
* full-confidence relocation: the original `line` still exists in the document,
|
|
381
|
+
* the candidate is farther than `proximityWindow` lines away, and the text now
|
|
382
|
+
* at the original position no longer equals `selected_text`. This is the
|
|
383
|
+
* signature of an in-place edit of the anchored text (which removed the
|
|
384
|
+
* original occurrence) rather than a genuine move of the selection.
|
|
385
|
+
*
|
|
386
|
+
* When the original line no longer exists (the document shrank or the section
|
|
387
|
+
* was removed) or no positional anchor is available, relocation is treated as a
|
|
388
|
+
* legitimate §7.4 step 3 contextual re-anchor and this returns false.
|
|
389
|
+
*/
|
|
390
|
+
function isImplausibleExactRelocation(comment, candidate, lines, proximityWindow) {
|
|
391
|
+
if (comment.line == null)
|
|
392
|
+
return false;
|
|
393
|
+
// Original position must still exist to be a viable fallback anchor.
|
|
394
|
+
if (comment.line <= 0 || comment.line >= lines.length)
|
|
395
|
+
return false;
|
|
396
|
+
// A nearby match is plausibly the same (or an adjacent) edit region.
|
|
397
|
+
if (Math.abs(candidate.line - comment.line) <= proximityWindow)
|
|
398
|
+
return false;
|
|
399
|
+
// If the text at the original position still equals selected_text, the
|
|
400
|
+
// original occurrence is intact and relocation is not a teleport.
|
|
401
|
+
const textAtOrigin = extractText(lines, comment.line, comment.end_line, comment.start_column, comment.end_column);
|
|
402
|
+
if (textAtOrigin === comment.selected_text)
|
|
403
|
+
return false;
|
|
404
|
+
return true;
|
|
405
|
+
}
|
|
283
406
|
function getLineShift(diffHunks, line) {
|
|
284
407
|
let shift = 0;
|
|
285
408
|
let modified = false;
|