@mrsf/cli 0.5.0 → 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.
@@ -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, Comment } from "./types.js";
5
- /** Result from a lenient (non-throwing) parse attempt. */
6
- export interface LenientParseResult {
7
- /** Fully parsed document, or null if parsing failed entirely. */
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).
@@ -3,7 +3,10 @@
3
3
  */
4
4
  import { readFile } from "node:fs/promises";
5
5
  import path from "node:path";
6
- import yaml from "js-yaml";
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,33 @@
1
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[];
16
28
  export declare function resolveAnchor(comment: Comment, documentText: string, opts?: {
17
29
  threshold?: number;
30
+ proximityWindow?: number;
18
31
  }): AnchorPosition;
19
32
  export declare function applyReanchorResults(doc: MrsfDocument, results: ReanchorResult[], opts?: {
20
33
  updateText?: 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
- const candidate = exactCandidates[0];
59
- return {
60
- commentId,
61
- status: "anchored",
62
- score: 1.0,
63
- newLine: candidate.line,
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 (exactCandidates.length > 1 && comment.line != null) {
71
- const best = closestToLine(exactCandidates, comment.line);
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: best.line,
77
- newEndLine: best.endLine,
78
- newStartColumn: best.startColumn,
79
- newEndColumn: best.endColumn,
80
- reason: `Exact text match (${exactCandidates.length} occurrences; chose nearest to original line ${comment.line}).`,
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);
@@ -342,6 +373,36 @@ function extractText(lines, line, endLine, startColumn, endColumn) {
342
373
  function closestToLine(candidates, targetLine) {
343
374
  return candidates.reduce((best, candidate) => Math.abs(candidate.line - targetLine) < Math.abs(best.line - targetLine) ? candidate : best);
344
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
+ }
345
406
  function getLineShift(diffHunks, line) {
346
407
  let shift = 0;
347
408
  let modified = false;
@@ -31,6 +31,7 @@ export { applyReanchorResults, DEFAULT_THRESHOLD, HIGH_THRESHOLD, reanchorCommen
31
31
  export async function reanchorDocument(doc, documentLines, opts = {}) {
32
32
  const results = [];
33
33
  const threshold = opts.threshold ?? DEFAULT_THRESHOLD;
34
+ const proximityWindow = opts.proximityWindow;
34
35
  // Determine git context
35
36
  let diffHunks;
36
37
  let commitIsStale = false;
@@ -51,17 +52,18 @@ export async function reanchorDocument(doc, documentLines, opts = {}) {
51
52
  diffHunks: hunks,
52
53
  threshold,
53
54
  commitIsStale: true,
55
+ proximityWindow,
54
56
  });
55
57
  results.push(result);
56
58
  continue;
57
59
  }
58
60
  // non-stale or no commit
59
- results.push(reanchorComment(comment, documentLines, { threshold, commitIsStale: false }));
61
+ results.push(reanchorComment(comment, documentLines, { threshold, commitIsStale: false, proximityWindow }));
60
62
  }
61
63
  return results;
62
64
  }
63
65
  }
64
- return reanchorDocumentLines(doc, documentLines, { threshold });
66
+ return reanchorDocumentLines(doc, documentLines, { threshold, proximityWindow });
65
67
  }
66
68
  /**
67
69
  * High-level re-anchor for a single sidecar file path.
@@ -0,0 +1,37 @@
1
+ /**
2
+ * MRSF string (de)serialization — pure, dependency-light helpers that parse and
3
+ * serialize MRSF sidecar content to/from strings.
4
+ *
5
+ * This module intentionally has **no Node-only imports** (no `node:fs`,
6
+ * `node:path`, `node:crypto`, …) so it can be consumed from browser/host
7
+ * adapters via the slim `@mrsf/cli/browser` entry point. Filesystem-bound
8
+ * helpers (read/discover/write a file) live in `parser.ts` / `writer.ts`.
9
+ */
10
+ import type { MrsfDocument, Comment } from "./types.js";
11
+ /** Result from a lenient (non-throwing) parse attempt. */
12
+ export interface LenientParseResult {
13
+ /** Fully parsed document, or null if parsing failed entirely. */
14
+ doc: MrsfDocument | null;
15
+ /** If parsing failed or produced warnings, the error message. */
16
+ error?: string;
17
+ /** Comments that could be salvaged from a partially-corrupted sidecar. */
18
+ partialComments?: Comment[];
19
+ }
20
+ /**
21
+ * Parse MRSF sidecar content from a string.
22
+ * Detects JSON vs YAML based on content or optional filename hint.
23
+ */
24
+ export declare function parseSidecarContent(content: string, filenameHint?: string): MrsfDocument;
25
+ /**
26
+ * Lenient parse from string content.
27
+ */
28
+ export declare function parseSidecarContentLenient(content: string, filenameHint?: string): LenientParseResult;
29
+ /**
30
+ * Serialize an MrsfDocument to YAML (for new files / non-round-trip use).
31
+ */
32
+ export declare function toYaml(doc: MrsfDocument): string;
33
+ /**
34
+ * Serialize an MrsfDocument to JSON.
35
+ */
36
+ export declare function toJson(doc: MrsfDocument): string;
37
+ //# sourceMappingURL=serialize.d.ts.map
@@ -0,0 +1,170 @@
1
+ /**
2
+ * MRSF string (de)serialization — pure, dependency-light helpers that parse and
3
+ * serialize MRSF sidecar content to/from strings.
4
+ *
5
+ * This module intentionally has **no Node-only imports** (no `node:fs`,
6
+ * `node:path`, `node:crypto`, …) so it can be consumed from browser/host
7
+ * adapters via the slim `@mrsf/cli/browser` entry point. Filesystem-bound
8
+ * helpers (read/discover/write a file) live in `parser.ts` / `writer.ts`.
9
+ */
10
+ import yaml from "js-yaml";
11
+ import { Document } from "yaml";
12
+ // ---------------------------------------------------------------------------
13
+ // Parsing (string → document)
14
+ // ---------------------------------------------------------------------------
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
+ /**
47
+ * Lenient parse from string content.
48
+ */
49
+ export function parseSidecarContentLenient(content, filenameHint) {
50
+ const trimmed = content.trim();
51
+ if (!trimmed) {
52
+ return { doc: null, error: "File is empty" };
53
+ }
54
+ const isJson = trimmed.startsWith("{") ||
55
+ (filenameHint && filenameHint.endsWith(".review.json"));
56
+ // First, try a normal parse
57
+ let parsed;
58
+ try {
59
+ parsed = isJson ? JSON.parse(trimmed) : yaml.load(trimmed, { schema: yaml.JSON_SCHEMA });
60
+ }
61
+ catch (e) {
62
+ // Total parse failure — try to salvage what we can from YAML
63
+ if (!isJson) {
64
+ return salvageYaml(trimmed);
65
+ }
66
+ return { doc: null, error: `Failed to parse JSON: ${e.message}` };
67
+ }
68
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
69
+ return { doc: null, error: "MRSF sidecar must be a YAML/JSON object" };
70
+ }
71
+ const obj = parsed;
72
+ const doc = {
73
+ mrsf_version: typeof obj.mrsf_version === "string" ? obj.mrsf_version : "1.0",
74
+ document: typeof obj.document === "string" ? obj.document : "unknown",
75
+ comments: [],
76
+ };
77
+ if (!Array.isArray(obj.comments)) {
78
+ return {
79
+ doc,
80
+ error: "comments field is not an array — file may be corrupted",
81
+ };
82
+ }
83
+ // Validate each comment individually
84
+ const good = [];
85
+ const bad = [];
86
+ for (let i = 0; i < obj.comments.length; i++) {
87
+ const c = obj.comments[i];
88
+ if (c && typeof c === "object" && !Array.isArray(c) && typeof c.id === "string") {
89
+ good.push(c);
90
+ }
91
+ else {
92
+ bad.push(i);
93
+ }
94
+ }
95
+ doc.comments = good;
96
+ if (bad.length > 0) {
97
+ return {
98
+ doc,
99
+ error: `${bad.length} comment(s) at indices [${bad.join(", ")}] were malformed and skipped`,
100
+ partialComments: good,
101
+ };
102
+ }
103
+ return { doc };
104
+ }
105
+ /**
106
+ * Attempt to extract individual comment blocks from corrupted YAML by
107
+ * splitting on `- id:` patterns and parsing each block independently.
108
+ */
109
+ function salvageYaml(content) {
110
+ const salvaged = [];
111
+ let mrsf_version = "1.0";
112
+ let document = "unknown";
113
+ // Try to extract top-level fields from the beginning
114
+ const versionMatch = content.match(/^mrsf_version:\s*["']?([^"'\n]+)/m);
115
+ if (versionMatch)
116
+ mrsf_version = versionMatch[1].trim();
117
+ const docMatch = content.match(/^document:\s*["']?([^"'\n]+)/m);
118
+ if (docMatch)
119
+ document = docMatch[1].trim();
120
+ // Split on comment block boundaries (- id: ...)
121
+ const blocks = content.split(/(?=^ - id:\s)/m);
122
+ for (const block of blocks) {
123
+ const trimmed = block.trim();
124
+ if (!trimmed.startsWith("- id:"))
125
+ continue;
126
+ // Wrap in a minimal YAML array context and try to parse
127
+ try {
128
+ const parsed = yaml.load(trimmed, { schema: yaml.JSON_SCHEMA });
129
+ if (Array.isArray(parsed) && parsed.length > 0) {
130
+ const c = parsed[0];
131
+ if (c && typeof c === "object" && typeof c.id === "string") {
132
+ salvaged.push(c);
133
+ }
134
+ }
135
+ else if (parsed && typeof parsed === "object" && typeof parsed.id === "string") {
136
+ salvaged.push(parsed);
137
+ }
138
+ }
139
+ catch {
140
+ // This block is unparseable — skip
141
+ }
142
+ }
143
+ const doc = {
144
+ mrsf_version,
145
+ document,
146
+ comments: salvaged,
147
+ };
148
+ return {
149
+ doc: salvaged.length > 0 ? doc : null,
150
+ error: `YAML parse failed. Salvaged ${salvaged.length} comment(s) from raw content.`,
151
+ partialComments: salvaged.length > 0 ? salvaged : undefined,
152
+ };
153
+ }
154
+ // ---------------------------------------------------------------------------
155
+ // Serialization (document → string)
156
+ // ---------------------------------------------------------------------------
157
+ /**
158
+ * Serialize an MrsfDocument to YAML (for new files / non-round-trip use).
159
+ */
160
+ export function toYaml(doc) {
161
+ const yamlDoc = new Document(doc);
162
+ return yamlDoc.toString({ lineWidth: 0 });
163
+ }
164
+ /**
165
+ * Serialize an MrsfDocument to JSON.
166
+ */
167
+ export function toJson(doc) {
168
+ return JSON.stringify(doc, null, 2) + "\n";
169
+ }
170
+ //# sourceMappingURL=serialize.js.map
@@ -172,6 +172,13 @@ export interface ReanchorOptions extends BaseOptions {
172
172
  fromCommit?: string;
173
173
  /** Update selected_text to match anchored_text (opt-in per §6.2). */
174
174
  updateText?: boolean;
175
+ /**
176
+ * Proximity window (in lines) for the §7.4 step 1a relocation guard.
177
+ * A lone exact match farther than this from the original line, when the
178
+ * original position's text has changed, is kept in place and flagged for
179
+ * re-anchoring rather than relocated with full confidence. Default 5.
180
+ */
181
+ proximityWindow?: number;
175
182
  /** Force-anchor: update commit to HEAD and clear audit fields for high-confidence results. */
176
183
  force?: boolean;
177
184
  }
@@ -21,14 +21,7 @@ export declare function computeHash(text: string): string;
21
21
  * If selected_text is absent, removes the hash.
22
22
  */
23
23
  export declare function syncHash(comment: Comment): Comment;
24
- /**
25
- * Serialize an MrsfDocument to YAML (for new files / non-round-trip use).
26
- */
27
- export declare function toYaml(doc: MrsfDocument): string;
28
- /**
29
- * Serialize an MrsfDocument to JSON.
30
- */
31
- export declare function toJson(doc: MrsfDocument): string;
24
+ export { toYaml, toJson } from "./serialize.js";
32
25
  /**
33
26
  * Write an MrsfDocument to disk.
34
27
  *
@@ -14,7 +14,8 @@ import { readFile, writeFile, rename, unlink } from "node:fs/promises";
14
14
  import { existsSync } from "node:fs";
15
15
  import { createHash, randomBytes } from "node:crypto";
16
16
  import path from "node:path";
17
- import { Document, Parser, CST, parse as yamlParse } from "yaml";
17
+ import { Parser, CST, parse as yamlParse } from "yaml";
18
+ import { toYaml, toJson } from "./serialize.js";
18
19
  /* ------------------------------------------------------------------ */
19
20
  /* Per-file write serialization */
20
21
  /* ------------------------------------------------------------------ */
@@ -327,19 +328,9 @@ function deepEqual(a, b) {
327
328
  /* ------------------------------------------------------------------ */
328
329
  /* Public API */
329
330
  /* ------------------------------------------------------------------ */
330
- /**
331
- * Serialize an MrsfDocument to YAML (for new files / non-round-trip use).
332
- */
333
- export function toYaml(doc) {
334
- const yamlDoc = new Document(doc);
335
- return yamlDoc.toString({ lineWidth: 0 });
336
- }
337
- /**
338
- * Serialize an MrsfDocument to JSON.
339
- */
340
- export function toJson(doc) {
341
- return JSON.stringify(doc, null, 2) + "\n";
342
- }
331
+ // `toYaml` / `toJson` now live in the Node-free serialize module; re-exported
332
+ // here so existing `@mrsf/cli` import paths keep working.
333
+ export { toYaml, toJson } from "./serialize.js";
343
334
  /**
344
335
  * Write an MrsfDocument to disk.
345
336
  *