@mrsf/cli 0.3.1 → 0.4.1

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 CHANGED
@@ -82,6 +82,15 @@ mrsf add docs/api.md \
82
82
  --author "alice" \
83
83
  --text "Good catch, fixed in abc123" \
84
84
  --reply-to 3eeccbd3
85
+
86
+ # Attach tool-specific x_* metadata
87
+ mrsf add docs/api.md \
88
+ --author "bot" \
89
+ --text "Flagging this for follow-up" \
90
+ --line 27 \
91
+ --ext x_source=triage-bot \
92
+ --ext x_score=0.91 \
93
+ --ext 'x_labels=["needs-review","docs"]'
85
94
  ```
86
95
 
87
96
  | Option | Description |
@@ -96,6 +105,9 @@ mrsf add docs/api.md \
96
105
  | `--severity <level>` | Importance: `low`, `medium`, `high` |
97
106
  | `--reply-to <id>` | Reply to an existing comment by ID |
98
107
  | `--selected-text <text>` | Override auto-detected selected text |
108
+ | `--ext <key=value>` | Add an `x_*` extension field; repeat for multiple fields |
109
+
110
+ `--ext` values are stored as flat `x_*` fields in the sidecar. Primitive JSON literals like numbers, `true`, `false`, and `null` are parsed automatically, and objects/arrays can be passed as JSON.
99
111
 
100
112
  ---
101
113
 
@@ -372,6 +384,26 @@ const updated = addComment(doc, lines, {
372
384
  await writeSidecar("docs/api.md.review.yaml", updated);
373
385
  ```
374
386
 
387
+ ### Browser-safe reanchoring
388
+
389
+ The default `@mrsf/cli` entrypoint is Node-oriented and includes file system and git-backed helpers such as `parseSidecar`, `readDocumentLines`, and `reanchorFile`. For browser bundles and editor integrations, use the `@mrsf/cli/browser` subpath instead.
390
+
391
+ `@mrsf/cli/browser` exports the pure text-based matching and reanchoring primitives only:
392
+
393
+ ```typescript
394
+ import {
395
+ applyReanchorResults,
396
+ reanchorDocumentText,
397
+ } from "@mrsf/cli/browser";
398
+
399
+ const results = reanchorDocumentText(sidecar, markdownText);
400
+ const changed = applyReanchorResults(sidecar, results);
401
+
402
+ console.log(changed, results.map((result) => result.status));
403
+ ```
404
+
405
+ Use this entrypoint when you already have the document text and sidecar object in memory. It intentionally excludes Node-only helpers such as sidecar discovery, file parsing, file writing, and git-aware diff reanchoring.
406
+
375
407
  ## Configuration
376
408
 
377
409
  Place a `.mrsf.yaml` at your repository root to configure sidecar discovery:
@@ -0,0 +1,4 @@
1
+ export type { Comment, DiffHunk, FuzzyCandidate, MrsfDocument, ReanchorResult, ReanchorStatus, } from "./lib/types.js";
2
+ export { combinedScore, exactMatch, fuzzySearch, levenshteinScore, normalizedMatch, tokenLcsScore, } from "./lib/fuzzy.js";
3
+ export { applyReanchorResults, DEFAULT_THRESHOLD, HIGH_THRESHOLD, reanchorComment, reanchorDocumentLines, reanchorDocumentText, toReanchorLines, } from "./lib/reanchor-core.js";
4
+ //# sourceMappingURL=browser.d.ts.map
@@ -0,0 +1,3 @@
1
+ export { combinedScore, exactMatch, fuzzySearch, levenshteinScore, normalizedMatch, tokenLcsScore, } from "./lib/fuzzy.js";
2
+ export { applyReanchorResults, DEFAULT_THRESHOLD, HIGH_THRESHOLD, reanchorComment, reanchorDocumentLines, reanchorDocumentText, toReanchorLines, } from "./lib/reanchor-core.js";
3
+ //# sourceMappingURL=browser.js.map
@@ -8,6 +8,46 @@ import { parseSidecar, readDocumentLines } from "../lib/parser.js";
8
8
  import { writeSidecar } from "../lib/writer.js";
9
9
  import { addComment, populateSelectedText } from "../lib/comments.js";
10
10
  import { findRepoRoot } from "../lib/git.js";
11
+ function collectOptionValues(value, previous) {
12
+ previous.push(value);
13
+ return previous;
14
+ }
15
+ function parseExtensionValue(rawValue) {
16
+ const trimmed = rawValue.trim();
17
+ if (/^(true|false|null)$/u.test(trimmed)) {
18
+ return JSON.parse(trimmed);
19
+ }
20
+ if (/^-?(0|[1-9]\d*)(\.\d+)?([eE][+-]?\d+)?$/u.test(trimmed)) {
21
+ return JSON.parse(trimmed);
22
+ }
23
+ if ((trimmed.startsWith("{") && trimmed.endsWith("}")) || (trimmed.startsWith("[") && trimmed.endsWith("]"))) {
24
+ try {
25
+ return JSON.parse(trimmed);
26
+ }
27
+ catch (error) {
28
+ const message = error instanceof Error ? error.message : String(error);
29
+ throw new Error(`Invalid JSON extension value '${rawValue}': ${message}`);
30
+ }
31
+ }
32
+ return rawValue;
33
+ }
34
+ function parseExtensionFlags(flags) {
35
+ if (!flags || flags.length === 0)
36
+ return undefined;
37
+ const entries = flags.map((flag) => {
38
+ const separatorIndex = flag.indexOf("=");
39
+ if (separatorIndex <= 0) {
40
+ throw new Error(`Invalid --ext value '${flag}'. Expected key=value.`);
41
+ }
42
+ const key = flag.slice(0, separatorIndex).trim();
43
+ const rawValue = flag.slice(separatorIndex + 1);
44
+ if (!key) {
45
+ throw new Error(`Invalid --ext value '${flag}'. Expected a non-empty key.`);
46
+ }
47
+ return [key, parseExtensionValue(rawValue)];
48
+ });
49
+ return Object.fromEntries(entries);
50
+ }
11
51
  export function registerAdd(program) {
12
52
  program
13
53
  .command("add <document>")
@@ -22,6 +62,7 @@ export function registerAdd(program) {
22
62
  .option("--severity <level>", "Severity: low | medium | high")
23
63
  .option("--reply-to <id>", "Reply to an existing comment")
24
64
  .option("--selected-text <text>", "Selected text to attach")
65
+ .option("--ext <key=value>", "Comment extension field keyed by x_* (repeatable)", collectOptionValues, [])
25
66
  .action(async (document, opts) => {
26
67
  const parentOpts = program.opts();
27
68
  const cwd = parentOpts.cwd ?? process.cwd();
@@ -58,6 +99,7 @@ export function registerAdd(program) {
58
99
  type: opts.type,
59
100
  severity: opts.severity,
60
101
  reply_to: opts.replyTo,
102
+ extensions: parseExtensionFlags(opts.ext),
61
103
  }, repoRoot ?? undefined);
62
104
  // Auto-populate selected_text from the document if not provided
63
105
  if (opts.selectedText) {
@@ -253,6 +253,7 @@ export function registerWatch(program) {
253
253
  }
254
254
  // ── Graceful shutdown ────────────────────────────────
255
255
  async function shutdown() {
256
+ cleanupSignalHandlers();
256
257
  // Clear pending debounce timers
257
258
  for (const t of timers.values())
258
259
  clearTimeout(t);
@@ -266,8 +267,19 @@ export function registerWatch(program) {
266
267
  }
267
268
  process.exit(totalErrors > 0 ? 1 : 0);
268
269
  }
269
- process.on("SIGINT", () => void shutdown());
270
- process.on("SIGTERM", () => void shutdown());
270
+ const onSigint = () => {
271
+ void shutdown();
272
+ };
273
+ const onSigterm = () => {
274
+ void shutdown();
275
+ };
276
+ function cleanupSignalHandlers() {
277
+ process.off("SIGINT", onSigint);
278
+ process.off("SIGTERM", onSigterm);
279
+ }
280
+ process.on("SIGINT", onSigint);
281
+ process.on("SIGTERM", onSigterm);
282
+ watcher.once("close", cleanupSignalHandlers);
271
283
  // Keep the process alive
272
284
  await new Promise(() => {
273
285
  // Never resolves — the process runs until interrupted
package/dist/index.d.ts CHANGED
@@ -4,7 +4,7 @@
4
4
  * Usage:
5
5
  * import { validate, reanchorFile, addComment, ... } from "mrsf";
6
6
  */
7
- export type { MrsfDocument, Comment, MrsfConfig, DiagnosticSeverity, ValidationDiagnostic, ValidationResult, ReanchorStatus, ReanchorResult, FuzzyCandidate, DiffHunk, AddCommentOptions, CommentFilter, AnchorHealth, StatusResult, BaseOptions, ReanchorOptions, ValidateOptions, } from "./lib/types.js";
7
+ export type { MrsfDocument, Comment, CommentExtensions, CommentExtensionValue, MrsfConfig, DiagnosticSeverity, ValidationDiagnostic, ValidationResult, ReanchorStatus, ReanchorResult, FuzzyCandidate, DiffHunk, AddCommentOptions, CommentFilter, AnchorHealth, StatusResult, BaseOptions, ReanchorOptions, ValidateOptions, } from "./lib/types.js";
8
8
  export { findWorkspaceRoot, loadConfig, discoverSidecar, sidecarToDocument, discoverAllSidecars, } from "./lib/discovery.js";
9
9
  export { resolveSidecarPaths } from "./lib/resolve-files.js";
10
10
  export { parseSidecar, parseSidecarContent, parseSidecarLenient, parseSidecarContentLenient, readDocumentLines, } from "./lib/parser.js";
@@ -13,7 +13,7 @@ export { computeHash, syncHash, toYaml, toJson, writeSidecar, } from "./lib/writ
13
13
  export { validate, validateFile } from "./lib/validator.js";
14
14
  export { exactMatch, normalizedMatch, fuzzySearch, combinedScore, } from "./lib/fuzzy.js";
15
15
  export { isGitAvailable, findRepoRoot, getCurrentCommit, isStale, getDiff, getLineShift, getFileAtCommit, getStagedFiles, detectRenames, parseDiffHunks, } from "./lib/git.js";
16
- export { reanchorComment, reanchorDocument, applyReanchorResults, reanchorFile, } from "./lib/reanchor.js";
17
- export { addComment, populateSelectedText, resolveComment, unresolveComment, removeComment, filterComments, getThreads, summarize, } from "./lib/comments.js";
16
+ export { DEFAULT_THRESHOLD, HIGH_THRESHOLD, reanchorComment, reanchorDocumentLines, reanchorDocumentText, toReanchorLines, reanchorDocument, applyReanchorResults, reanchorFile, } from "./lib/reanchor.js";
17
+ export { addComment, normalizeCommentExtensions, populateSelectedText, resolveComment, unresolveComment, removeComment, filterComments, getThreads, summarize, } from "./lib/comments.js";
18
18
  export type { CommentSummary, RemoveCommentOptions } from "./lib/comments.js";
19
19
  //# sourceMappingURL=index.d.ts.map
package/dist/index.js CHANGED
@@ -19,7 +19,7 @@ export { exactMatch, normalizedMatch, fuzzySearch, combinedScore, } from "./lib/
19
19
  // Git
20
20
  export { isGitAvailable, findRepoRoot, getCurrentCommit, isStale, getDiff, getLineShift, getFileAtCommit, getStagedFiles, detectRenames, parseDiffHunks, } from "./lib/git.js";
21
21
  // Re-anchoring
22
- export { reanchorComment, reanchorDocument, applyReanchorResults, reanchorFile, } from "./lib/reanchor.js";
22
+ export { DEFAULT_THRESHOLD, HIGH_THRESHOLD, reanchorComment, reanchorDocumentLines, reanchorDocumentText, toReanchorLines, reanchorDocument, applyReanchorResults, reanchorFile, } from "./lib/reanchor.js";
23
23
  // Comments
24
- export { addComment, populateSelectedText, resolveComment, unresolveComment, removeComment, filterComments, getThreads, summarize, } from "./lib/comments.js";
24
+ export { addComment, normalizeCommentExtensions, populateSelectedText, resolveComment, unresolveComment, removeComment, filterComments, getThreads, summarize, } from "./lib/comments.js";
25
25
  //# sourceMappingURL=index.js.map
@@ -1,7 +1,8 @@
1
1
  /**
2
2
  * MRSF Comment Operations — CRUD for sidecar comments.
3
3
  */
4
- import type { AddCommentOptions, Comment, CommentFilter, MrsfDocument } from "./types.js";
4
+ import type { AddCommentOptions, Comment, CommentFilter, CommentExtensions, MrsfDocument } from "./types.js";
5
+ export declare function normalizeCommentExtensions(extensions?: Record<string, unknown>): CommentExtensions;
5
6
  /**
6
7
  * Add a new comment to a document. Mutates doc.comments in place.
7
8
  * Returns the created comment.
@@ -7,6 +7,49 @@ import { getCurrentCommit, isGitAvailable } from "./git.js";
7
7
  // ---------------------------------------------------------------------------
8
8
  // Add
9
9
  // ---------------------------------------------------------------------------
10
+ function isPlainObject(value) {
11
+ if (value === null || typeof value !== "object")
12
+ return false;
13
+ const prototype = Object.getPrototypeOf(value);
14
+ return prototype === Object.prototype || prototype === null;
15
+ }
16
+ function isCommentExtensionValue(value) {
17
+ if (value == null)
18
+ return true;
19
+ switch (typeof value) {
20
+ case "boolean":
21
+ case "string":
22
+ return true;
23
+ case "number":
24
+ return Number.isFinite(value);
25
+ case "object": {
26
+ if (Array.isArray(value)) {
27
+ return value.every((item) => isCommentExtensionValue(item));
28
+ }
29
+ if (!isPlainObject(value)) {
30
+ return false;
31
+ }
32
+ return Object.values(value).every((item) => isCommentExtensionValue(item));
33
+ }
34
+ default:
35
+ return false;
36
+ }
37
+ }
38
+ export function normalizeCommentExtensions(extensions) {
39
+ if (!extensions)
40
+ return {};
41
+ const normalizedEntries = [];
42
+ for (const [key, value] of Object.entries(extensions)) {
43
+ if (!key.startsWith("x_")) {
44
+ throw new Error(`Comment extension key '${key}' must start with 'x_'.`);
45
+ }
46
+ if (!isCommentExtensionValue(value)) {
47
+ throw new Error(`Comment extension '${key}' must be JSON-serializable (null, boolean, finite number, string, array, or plain object).`);
48
+ }
49
+ normalizedEntries.push([key, value]);
50
+ }
51
+ return Object.fromEntries(normalizedEntries);
52
+ }
10
53
  /**
11
54
  * Add a new comment to a document. Mutates doc.comments in place.
12
55
  * Returns the created comment.
@@ -43,6 +86,7 @@ export async function addComment(doc, opts, repoRoot) {
43
86
  comment.reply_to = opts.reply_to;
44
87
  if (commit)
45
88
  comment.commit = commit;
89
+ Object.assign(comment, normalizeCommentExtensions(opts.extensions));
46
90
  doc.comments.push(comment);
47
91
  return comment;
48
92
  }
@@ -0,0 +1,21 @@
1
+ import type { Comment, DiffHunk, MrsfDocument, ReanchorResult } from "./types.js";
2
+ export declare const HIGH_THRESHOLD = 0.8;
3
+ export declare const DEFAULT_THRESHOLD = 0.6;
4
+ export declare function toReanchorLines(documentText: string): string[];
5
+ export declare function reanchorComment(comment: Comment, documentLines: string[], opts?: {
6
+ diffHunks?: DiffHunk[];
7
+ threshold?: number;
8
+ commitIsStale?: boolean;
9
+ }): ReanchorResult;
10
+ export declare function reanchorDocumentLines(doc: MrsfDocument, documentLines: string[], opts?: {
11
+ threshold?: number;
12
+ }): ReanchorResult[];
13
+ export declare function reanchorDocumentText(doc: MrsfDocument, documentText: string, opts?: {
14
+ threshold?: number;
15
+ }): ReanchorResult[];
16
+ export declare function applyReanchorResults(doc: MrsfDocument, results: ReanchorResult[], opts?: {
17
+ updateText?: boolean;
18
+ force?: boolean;
19
+ headCommit?: string;
20
+ }): number;
21
+ //# sourceMappingURL=reanchor-core.d.ts.map
@@ -0,0 +1,298 @@
1
+ import { exactMatch, fuzzySearch, normalizedMatch, } from "./fuzzy.js";
2
+ export const HIGH_THRESHOLD = 0.8;
3
+ export const DEFAULT_THRESHOLD = 0.6;
4
+ export function toReanchorLines(documentText) {
5
+ return ["", ...documentText.replace(/\r\n/g, "\n").split("\n")];
6
+ }
7
+ export function reanchorComment(comment, documentLines, opts = {}) {
8
+ const threshold = opts.threshold ?? DEFAULT_THRESHOLD;
9
+ const commentId = comment.id;
10
+ const selectedText = comment.selected_text;
11
+ if (!selectedText && comment.line == null) {
12
+ return {
13
+ commentId,
14
+ status: "anchored",
15
+ score: 1.0,
16
+ reason: "Document-level comment (no anchor needed).",
17
+ };
18
+ }
19
+ if (comment.line != null && opts.diffHunks?.length) {
20
+ const { shift, modified } = getLineShift(opts.diffHunks, comment.line);
21
+ if (!selectedText) {
22
+ const shiftedLine = comment.line + shift;
23
+ const lineSpan = comment.end_line != null ? comment.end_line - comment.line : 0;
24
+ const shiftedEndLine = comment.end_line != null ? shiftedLine + lineSpan : undefined;
25
+ return {
26
+ commentId,
27
+ status: shift === 0 ? "anchored" : "shifted",
28
+ score: 1.0,
29
+ newLine: shiftedLine,
30
+ newEndLine: shiftedEndLine,
31
+ reason: shift === 0
32
+ ? "Line-only comment unchanged (diff confirms position)."
33
+ : `Line-only comment shifted by ${shift > 0 ? "+" : ""}${shift} line(s) via diff.`,
34
+ };
35
+ }
36
+ if (!modified) {
37
+ const shiftedLine = comment.line + shift;
38
+ const lineSpan = comment.end_line != null ? comment.end_line - comment.line : 0;
39
+ const shiftedEndLine = comment.end_line != null ? shiftedLine + lineSpan : undefined;
40
+ const textAtShifted = extractText(documentLines, shiftedLine, shiftedEndLine, comment.start_column, comment.end_column);
41
+ if (textAtShifted === selectedText) {
42
+ return {
43
+ commentId,
44
+ status: shift === 0 ? "anchored" : "shifted",
45
+ score: 1.0,
46
+ newLine: shiftedLine,
47
+ newEndLine: shiftedEndLine,
48
+ reason: shift === 0
49
+ ? "Diff confirms text unchanged at original position."
50
+ : `Diff shifted by ${shift > 0 ? "+" : ""}${shift} line(s).`,
51
+ };
52
+ }
53
+ }
54
+ }
55
+ if (selectedText) {
56
+ const exactCandidates = exactMatch(documentLines, selectedText);
57
+ 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
+ };
69
+ }
70
+ if (exactCandidates.length > 1 && comment.line != null) {
71
+ const best = closestToLine(exactCandidates, comment.line);
72
+ return {
73
+ commentId,
74
+ status: "anchored",
75
+ 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}).`,
81
+ };
82
+ }
83
+ const normCandidates = normalizedMatch(documentLines, selectedText);
84
+ if (normCandidates.length === 1) {
85
+ const candidate = normCandidates[0];
86
+ return {
87
+ commentId,
88
+ status: "fuzzy",
89
+ score: candidate.score,
90
+ newLine: candidate.line,
91
+ newEndLine: candidate.endLine,
92
+ newStartColumn: candidate.startColumn,
93
+ newEndColumn: candidate.endColumn,
94
+ anchoredText: candidate.text,
95
+ previousSelectedText: selectedText,
96
+ reason: "Normalized whitespace match.",
97
+ };
98
+ }
99
+ const fuzzyCandidates = fuzzySearch(documentLines, selectedText, HIGH_THRESHOLD, comment.line);
100
+ if (fuzzyCandidates.length === 1 || (fuzzyCandidates.length > 0 && fuzzyCandidates[0].score >= HIGH_THRESHOLD)) {
101
+ const best = fuzzyCandidates.length === 1
102
+ ? fuzzyCandidates[0]
103
+ : closestToLine(fuzzyCandidates, comment.line ?? 1);
104
+ return {
105
+ commentId,
106
+ status: "fuzzy",
107
+ score: best.score,
108
+ newLine: best.line,
109
+ newEndLine: best.endLine,
110
+ newStartColumn: best.startColumn,
111
+ newEndColumn: best.endColumn,
112
+ anchoredText: best.text,
113
+ previousSelectedText: selectedText,
114
+ reason: `High-confidence fuzzy match (score ${best.score.toFixed(3)}).`,
115
+ };
116
+ }
117
+ }
118
+ if (comment.line != null) {
119
+ const lineIdx = comment.line;
120
+ if (lineIdx > 0 && lineIdx < documentLines.length) {
121
+ const qualifier = opts.commitIsStale
122
+ ? " (commit is stale — line may have shifted)"
123
+ : "";
124
+ if (selectedText) {
125
+ const lineText = documentLines[lineIdx];
126
+ const candidates = fuzzySearch(["", lineText], selectedText, DEFAULT_THRESHOLD);
127
+ if (candidates.length > 0) {
128
+ return {
129
+ commentId,
130
+ status: "fuzzy",
131
+ score: candidates[0].score,
132
+ newLine: comment.line,
133
+ newEndLine: comment.end_line,
134
+ anchoredText: candidates[0].text,
135
+ previousSelectedText: selectedText,
136
+ reason: `Line-fallback with fuzzy text match (score ${candidates[0].score.toFixed(3)})${qualifier}.`,
137
+ };
138
+ }
139
+ }
140
+ const isLineOnly = !selectedText;
141
+ return {
142
+ commentId,
143
+ status: isLineOnly ? "anchored" : (opts.commitIsStale ? "ambiguous" : "anchored"),
144
+ score: isLineOnly ? 1.0 : (opts.commitIsStale ? 0.5 : 0.8),
145
+ newLine: comment.line,
146
+ newEndLine: comment.end_line,
147
+ reason: isLineOnly
148
+ ? "Line-only comment (no selected_text to verify)."
149
+ : `Line/column fallback${qualifier}.`,
150
+ };
151
+ }
152
+ }
153
+ if (selectedText) {
154
+ const lowCandidates = fuzzySearch(documentLines, selectedText, threshold, comment.line);
155
+ if (lowCandidates.length === 1) {
156
+ const candidate = lowCandidates[0];
157
+ return {
158
+ commentId,
159
+ status: "fuzzy",
160
+ score: candidate.score,
161
+ newLine: candidate.line,
162
+ newEndLine: candidate.endLine,
163
+ newStartColumn: candidate.startColumn,
164
+ newEndColumn: candidate.endColumn,
165
+ anchoredText: candidate.text,
166
+ previousSelectedText: selectedText,
167
+ reason: `Low-threshold fuzzy match (score ${candidate.score.toFixed(3)}).`,
168
+ };
169
+ }
170
+ if (lowCandidates.length > 1) {
171
+ const best = lowCandidates[0];
172
+ return {
173
+ commentId,
174
+ status: "ambiguous",
175
+ score: best.score,
176
+ newLine: best.line,
177
+ newEndLine: best.endLine,
178
+ reason: `Ambiguous: ${lowCandidates.length} fuzzy matches (best score ${best.score.toFixed(3)}).`,
179
+ };
180
+ }
181
+ }
182
+ return {
183
+ commentId,
184
+ status: "orphaned",
185
+ score: 0,
186
+ reason: "No match found. Comment is orphaned.",
187
+ };
188
+ }
189
+ export function reanchorDocumentLines(doc, documentLines, opts = {}) {
190
+ return doc.comments.map((comment) => reanchorComment(comment, documentLines, opts));
191
+ }
192
+ export function reanchorDocumentText(doc, documentText, opts = {}) {
193
+ return reanchorDocumentLines(doc, toReanchorLines(documentText), opts);
194
+ }
195
+ export function applyReanchorResults(doc, results, opts = {}) {
196
+ let changed = 0;
197
+ const resultMap = new Map(results.map((result) => [result.commentId, result]));
198
+ for (const comment of doc.comments) {
199
+ const result = resultMap.get(comment.id);
200
+ if (!result)
201
+ continue;
202
+ let isChanged = false;
203
+ if (result.newLine != null && result.newLine !== comment.line) {
204
+ comment.line = result.newLine;
205
+ isChanged = true;
206
+ }
207
+ if (result.newEndLine != null && result.newEndLine !== comment.end_line) {
208
+ comment.end_line = result.newEndLine;
209
+ isChanged = true;
210
+ }
211
+ if (result.newStartColumn != null && result.newStartColumn !== comment.start_column) {
212
+ comment.start_column = result.newStartColumn;
213
+ isChanged = true;
214
+ }
215
+ if (result.newEndColumn != null && result.newEndColumn !== comment.end_column) {
216
+ comment.end_column = result.newEndColumn;
217
+ isChanged = true;
218
+ }
219
+ if (result.anchoredText != null && result.anchoredText !== comment.selected_text) {
220
+ if (opts.updateText) {
221
+ comment.selected_text = result.anchoredText;
222
+ delete comment.anchored_text;
223
+ }
224
+ else {
225
+ comment.anchored_text = result.anchoredText;
226
+ }
227
+ isChanged = true;
228
+ }
229
+ else if (result.anchoredText != null && result.anchoredText === comment.selected_text) {
230
+ if (comment.anchored_text) {
231
+ delete comment.anchored_text;
232
+ isChanged = true;
233
+ }
234
+ }
235
+ if (isChanged || result.status !== "anchored") {
236
+ comment.x_reanchor_status = result.status;
237
+ comment.x_reanchor_score = result.score;
238
+ }
239
+ if (opts.force
240
+ && opts.headCommit
241
+ && (result.status === "anchored" || result.status === "shifted")
242
+ && result.score >= HIGH_THRESHOLD) {
243
+ comment.commit = opts.headCommit;
244
+ delete comment.x_reanchor_status;
245
+ delete comment.x_reanchor_score;
246
+ if (comment.anchored_text && comment.anchored_text === comment.selected_text) {
247
+ delete comment.anchored_text;
248
+ }
249
+ isChanged = true;
250
+ }
251
+ if (isChanged) {
252
+ changed += 1;
253
+ }
254
+ }
255
+ return changed;
256
+ }
257
+ function extractText(lines, line, endLine, startColumn, endColumn) {
258
+ const startIdx = line;
259
+ const endIdx = endLine ?? line;
260
+ if (startIdx < 1 || endIdx >= lines.length)
261
+ return null;
262
+ if (startIdx === endIdx) {
263
+ const text = lines[startIdx];
264
+ if (startColumn != null && endColumn != null) {
265
+ return text.slice(startColumn, endColumn);
266
+ }
267
+ return text;
268
+ }
269
+ const result = [];
270
+ for (let index = startIdx; index <= endIdx; index += 1) {
271
+ let currentLine = lines[index];
272
+ if (index === startIdx && startColumn != null)
273
+ currentLine = currentLine.slice(startColumn);
274
+ if (index === endIdx && endColumn != null)
275
+ currentLine = currentLine.slice(0, endColumn);
276
+ result.push(currentLine);
277
+ }
278
+ return result.join("\n");
279
+ }
280
+ function closestToLine(candidates, targetLine) {
281
+ return candidates.reduce((best, candidate) => Math.abs(candidate.line - targetLine) < Math.abs(best.line - targetLine) ? candidate : best);
282
+ }
283
+ function getLineShift(diffHunks, line) {
284
+ let shift = 0;
285
+ let modified = false;
286
+ for (const hunk of diffHunks) {
287
+ const oldStart = hunk.oldStart;
288
+ const oldEnd = hunk.oldStart + Math.max(hunk.oldCount, 1) - 1;
289
+ if (line >= oldStart && line <= oldEnd && hunk.oldCount > 0) {
290
+ modified = true;
291
+ }
292
+ if (line > oldEnd || (hunk.oldCount === 0 && line >= oldStart)) {
293
+ shift += hunk.newCount - hunk.oldCount;
294
+ }
295
+ }
296
+ return { shift, modified };
297
+ }
298
+ //# sourceMappingURL=reanchor-core.js.map
@@ -11,15 +11,8 @@
11
11
  * Step 3 – lower-threshold fuzzy ≥ configured threshold (0.6)
12
12
  * Step 4 – orphan
13
13
  */
14
- import type { Comment, MrsfDocument, ReanchorOptions, ReanchorResult, DiffHunk } from "./types.js";
15
- /**
16
- * Re-anchor a single comment against the current document lines.
17
- */
18
- export declare function reanchorComment(comment: Comment, documentLines: string[], opts?: {
19
- diffHunks?: DiffHunk[];
20
- threshold?: number;
21
- commitIsStale?: boolean;
22
- }): ReanchorResult;
14
+ import type { MrsfDocument, ReanchorOptions, ReanchorResult } from "./types.js";
15
+ export { applyReanchorResults, DEFAULT_THRESHOLD, HIGH_THRESHOLD, reanchorComment, reanchorDocumentLines, reanchorDocumentText, toReanchorLines, } from "./reanchor-core.js";
23
16
  /**
24
17
  * Re-anchor all comments in an MRSF document.
25
18
  */
@@ -27,15 +20,6 @@ export declare function reanchorDocument(doc: MrsfDocument, documentLines: strin
27
20
  documentPath?: string;
28
21
  repoRoot?: string;
29
22
  }): Promise<ReanchorResult[]>;
30
- /**
31
- * Apply re-anchor results to the document's comments.
32
- * Mutates the document in place and returns the number of changed comments.
33
- */
34
- export declare function applyReanchorResults(doc: MrsfDocument, results: ReanchorResult[], opts?: {
35
- updateText?: boolean;
36
- force?: boolean;
37
- headCommit?: string;
38
- }): number;
39
23
  /**
40
24
  * High-level re-anchor for a single sidecar file path.
41
25
  */
@@ -11,236 +11,17 @@
11
11
  * Step 3 – lower-threshold fuzzy ≥ configured threshold (0.6)
12
12
  * Step 4 – orphan
13
13
  */
14
- import { findRepoRoot, getCurrentCommit, getDiff, getLineShift, isGitAvailable, } from "./git.js";
15
- import { exactMatch, fuzzySearch, normalizedMatch, } from "./fuzzy.js";
14
+ import { findRepoRoot, getCurrentCommit, getDiff, isGitAvailable, } from "./git.js";
15
+ import { applyReanchorResults, DEFAULT_THRESHOLD, reanchorComment, reanchorDocumentLines, } from "./reanchor-core.js";
16
16
  import { readDocumentLines } from "./parser.js";
17
17
  import { sidecarToDocument } from "./discovery.js";
18
18
  import { parseSidecar } from "./parser.js";
19
19
  import { writeSidecar } from "./writer.js";
20
20
  import path from "node:path";
21
+ export { applyReanchorResults, DEFAULT_THRESHOLD, HIGH_THRESHOLD, reanchorComment, reanchorDocumentLines, reanchorDocumentText, toReanchorLines, } from "./reanchor-core.js";
21
22
  // ---------------------------------------------------------------------------
22
23
  // Constants
23
24
  // ---------------------------------------------------------------------------
24
- const HIGH_THRESHOLD = 0.8;
25
- const DEFAULT_THRESHOLD = 0.6;
26
- // ---------------------------------------------------------------------------
27
- // Single comment re-anchoring
28
- // ---------------------------------------------------------------------------
29
- /**
30
- * Re-anchor a single comment against the current document lines.
31
- */
32
- export function reanchorComment(comment, documentLines, opts = {}) {
33
- const threshold = opts.threshold ?? DEFAULT_THRESHOLD;
34
- const commentId = comment.id;
35
- const selectedText = comment.selected_text;
36
- // No selected_text and no line — nothing to anchor
37
- if (!selectedText && comment.line == null) {
38
- return {
39
- commentId,
40
- status: "anchored",
41
- score: 1.0,
42
- reason: "Document-level comment (no anchor needed).",
43
- };
44
- }
45
- // -----------------------------------------------------------------------
46
- // Step 0: Diff-based shift
47
- // -----------------------------------------------------------------------
48
- if (comment.line != null && opts.diffHunks?.length) {
49
- const { shift, modified } = getLineShift(opts.diffHunks, comment.line);
50
- // For line-only comments (no selected_text), apply the diff shift directly
51
- if (!selectedText) {
52
- const shiftedLine = comment.line + shift;
53
- const lineSpan = comment.end_line != null ? comment.end_line - comment.line : 0;
54
- const shiftedEndLine = comment.end_line != null ? shiftedLine + lineSpan : undefined;
55
- return {
56
- commentId,
57
- status: shift === 0 ? "anchored" : "shifted",
58
- score: 1.0,
59
- newLine: shiftedLine,
60
- newEndLine: shiftedEndLine,
61
- reason: shift === 0
62
- ? "Line-only comment unchanged (diff confirms position)."
63
- : `Line-only comment shifted by ${shift > 0 ? "+" : ""}${shift} line(s) via diff.`,
64
- };
65
- }
66
- if (!modified) {
67
- const shiftedLine = comment.line + shift;
68
- const lineSpan = comment.end_line != null ? comment.end_line - comment.line : 0;
69
- const shiftedEndLine = comment.end_line != null ? shiftedLine + lineSpan : undefined;
70
- // Verify the selected text still matches at the shifted position
71
- const textAtShifted = extractText(documentLines, shiftedLine, shiftedEndLine, comment.start_column, comment.end_column);
72
- if (textAtShifted === selectedText) {
73
- return {
74
- commentId,
75
- status: shift === 0 ? "anchored" : "shifted",
76
- score: 1.0,
77
- newLine: shiftedLine,
78
- newEndLine: shiftedEndLine,
79
- reason: shift === 0
80
- ? "Diff confirms text unchanged at original position."
81
- : `Diff shifted by ${shift > 0 ? "+" : ""}${shift} line(s).`,
82
- };
83
- }
84
- }
85
- // Fall through — line was modified or text doesn't match after shift
86
- }
87
- // -----------------------------------------------------------------------
88
- // Step 1: Exact text match
89
- // -----------------------------------------------------------------------
90
- if (selectedText) {
91
- const exactCandidates = exactMatch(documentLines, selectedText);
92
- if (exactCandidates.length === 1) {
93
- const c = exactCandidates[0];
94
- return {
95
- commentId,
96
- status: "anchored",
97
- score: 1.0,
98
- newLine: c.line,
99
- newEndLine: c.endLine,
100
- newStartColumn: c.startColumn,
101
- newEndColumn: c.endColumn,
102
- reason: "Exact text match (unique).",
103
- };
104
- }
105
- if (exactCandidates.length > 1 && comment.line != null) {
106
- // Disambiguate by proximity to original line
107
- const best = closestToLine(exactCandidates, comment.line);
108
- return {
109
- commentId,
110
- status: "anchored",
111
- score: 1.0,
112
- newLine: best.line,
113
- newEndLine: best.endLine,
114
- newStartColumn: best.startColumn,
115
- newEndColumn: best.endColumn,
116
- reason: `Exact text match (${exactCandidates.length} occurrences; chose nearest to original line ${comment.line}).`,
117
- };
118
- }
119
- // -----------------------------------------------------------------------
120
- // Step 1.5: Normalized + high-threshold fuzzy match
121
- // -----------------------------------------------------------------------
122
- // Try whitespace-normalized match first
123
- const normCandidates = normalizedMatch(documentLines, selectedText);
124
- if (normCandidates.length === 1) {
125
- const c = normCandidates[0];
126
- return {
127
- commentId,
128
- status: "fuzzy",
129
- score: c.score,
130
- newLine: c.line,
131
- newEndLine: c.endLine,
132
- newStartColumn: c.startColumn,
133
- newEndColumn: c.endColumn,
134
- anchoredText: c.text,
135
- previousSelectedText: selectedText,
136
- reason: "Normalized whitespace match.",
137
- };
138
- }
139
- // High threshold fuzzy
140
- const fuzzyCandidates = fuzzySearch(documentLines, selectedText, HIGH_THRESHOLD, comment.line);
141
- if (fuzzyCandidates.length === 1 || (fuzzyCandidates.length > 0 && fuzzyCandidates[0].score >= HIGH_THRESHOLD)) {
142
- const best = fuzzyCandidates.length === 1
143
- ? fuzzyCandidates[0]
144
- : closestToLine(fuzzyCandidates, comment.line ?? 1);
145
- return {
146
- commentId,
147
- status: "fuzzy",
148
- score: best.score,
149
- newLine: best.line,
150
- newEndLine: best.endLine,
151
- newStartColumn: best.startColumn,
152
- newEndColumn: best.endColumn,
153
- anchoredText: best.text,
154
- previousSelectedText: selectedText,
155
- reason: `High-confidence fuzzy match (score ${best.score.toFixed(3)}).`,
156
- };
157
- }
158
- }
159
- // -----------------------------------------------------------------------
160
- // Step 2: Line/column fallback
161
- // -----------------------------------------------------------------------
162
- if (comment.line != null) {
163
- const lineIdx = comment.line - 1;
164
- if (lineIdx >= 0 && lineIdx < documentLines.length) {
165
- const qualifier = opts.commitIsStale
166
- ? " (commit is stale — line may have shifted)"
167
- : "";
168
- // If we have selected_text, check if current line text is a decent fuzzy match
169
- if (selectedText) {
170
- const lineText = documentLines[lineIdx];
171
- const candidates = fuzzySearch([lineText], selectedText, DEFAULT_THRESHOLD);
172
- if (candidates.length > 0) {
173
- return {
174
- commentId,
175
- status: "fuzzy",
176
- score: candidates[0].score,
177
- newLine: comment.line,
178
- newEndLine: comment.end_line,
179
- anchoredText: candidates[0].text,
180
- previousSelectedText: selectedText,
181
- reason: `Line-fallback with fuzzy text match (score ${candidates[0].score.toFixed(3)})${qualifier}.`,
182
- };
183
- }
184
- }
185
- // Pure line fallback
186
- // If there's no selected_text, the comment is purely line-anchored.
187
- // A commit change alone doesn't make it ambiguous — we accept it as-is.
188
- const isLineOnly = !selectedText;
189
- return {
190
- commentId,
191
- status: isLineOnly ? "anchored" : (opts.commitIsStale ? "ambiguous" : "anchored"),
192
- score: isLineOnly ? 1.0 : (opts.commitIsStale ? 0.5 : 0.8),
193
- newLine: comment.line,
194
- newEndLine: comment.end_line,
195
- reason: isLineOnly
196
- ? "Line-only comment (no selected_text to verify)."
197
- : `Line/column fallback${qualifier}.`,
198
- };
199
- }
200
- }
201
- // -----------------------------------------------------------------------
202
- // Step 3: Lower-threshold fuzzy search
203
- // -----------------------------------------------------------------------
204
- if (selectedText) {
205
- const lowCandidates = fuzzySearch(documentLines, selectedText, threshold, comment.line);
206
- if (lowCandidates.length === 1) {
207
- const c = lowCandidates[0];
208
- return {
209
- commentId,
210
- status: "fuzzy",
211
- score: c.score,
212
- newLine: c.line,
213
- newEndLine: c.endLine,
214
- newStartColumn: c.startColumn,
215
- newEndColumn: c.endColumn,
216
- anchoredText: c.text,
217
- previousSelectedText: selectedText,
218
- reason: `Low-threshold fuzzy match (score ${c.score.toFixed(3)}).`,
219
- };
220
- }
221
- if (lowCandidates.length > 1) {
222
- // Ambiguous — multiple low-confidence matches
223
- const best = lowCandidates[0];
224
- return {
225
- commentId,
226
- status: "ambiguous",
227
- score: best.score,
228
- newLine: best.line,
229
- newEndLine: best.endLine,
230
- reason: `Ambiguous: ${lowCandidates.length} fuzzy matches (best score ${best.score.toFixed(3)}).`,
231
- };
232
- }
233
- }
234
- // -----------------------------------------------------------------------
235
- // Step 4: Orphan
236
- // -----------------------------------------------------------------------
237
- return {
238
- commentId,
239
- status: "orphaned",
240
- score: 0,
241
- reason: "No match found. Comment is orphaned.",
242
- };
243
- }
244
25
  // ---------------------------------------------------------------------------
245
26
  // Batch re-anchoring
246
27
  // ---------------------------------------------------------------------------
@@ -280,87 +61,7 @@ export async function reanchorDocument(doc, documentLines, opts = {}) {
280
61
  return results;
281
62
  }
282
63
  }
283
- // No git — pure text-based re-anchoring
284
- for (const comment of doc.comments) {
285
- results.push(reanchorComment(comment, documentLines, { threshold }));
286
- }
287
- return results;
288
- }
289
- /**
290
- * Apply re-anchor results to the document's comments.
291
- * Mutates the document in place and returns the number of changed comments.
292
- */
293
- export function applyReanchorResults(doc, results, opts = {}) {
294
- let changed = 0;
295
- const resultMap = new Map(results.map((r) => [r.commentId, r]));
296
- for (const comment of doc.comments) {
297
- const result = resultMap.get(comment.id);
298
- if (!result)
299
- continue;
300
- let isChanged = false;
301
- if (result.newLine != null && result.newLine !== comment.line) {
302
- comment.line = result.newLine;
303
- isChanged = true;
304
- }
305
- if (result.newEndLine != null && result.newEndLine !== comment.end_line) {
306
- comment.end_line = result.newEndLine;
307
- isChanged = true;
308
- }
309
- if (result.newStartColumn != null && result.newStartColumn !== comment.start_column) {
310
- comment.start_column = result.newStartColumn;
311
- isChanged = true;
312
- }
313
- if (result.newEndColumn != null && result.newEndColumn !== comment.end_column) {
314
- comment.end_column = result.newEndColumn;
315
- isChanged = true;
316
- }
317
- // Handle anchored_text / selected_text per §6.2 & §7.4 Step 3a.
318
- // By default, selected_text is preserved (SHOULD NOT modify).
319
- // anchored_text records the text currently at the resolved position.
320
- if (result.anchoredText != null && result.anchoredText !== comment.selected_text) {
321
- if (opts.updateText) {
322
- // Opt-in: replace selected_text and clear anchored_text
323
- comment.selected_text = result.anchoredText;
324
- delete comment.anchored_text;
325
- }
326
- else {
327
- // Default: preserve selected_text, record current text in anchored_text
328
- comment.anchored_text = result.anchoredText;
329
- }
330
- isChanged = true;
331
- }
332
- else if (result.anchoredText != null && result.anchoredText === comment.selected_text) {
333
- // Text matches exactly — clear any stale anchored_text
334
- if (comment.anchored_text) {
335
- delete comment.anchored_text;
336
- isChanged = true;
337
- }
338
- }
339
- // Audit fields (x_ extension prefix per §10)
340
- if (isChanged || result.status !== "anchored") {
341
- comment.x_reanchor_status = result.status;
342
- comment.x_reanchor_score = result.score;
343
- }
344
- // --force: firmly anchor high-confidence results
345
- // Update commit to HEAD and clear audit fields so the comment
346
- // won't be re-evaluated on the next reanchor run.
347
- if (opts.force &&
348
- opts.headCommit &&
349
- (result.status === "anchored" || result.status === "shifted") &&
350
- result.score >= HIGH_THRESHOLD) {
351
- comment.commit = opts.headCommit;
352
- delete comment.x_reanchor_status;
353
- delete comment.x_reanchor_score;
354
- // Clear stale anchored_text since anchor is now confirmed
355
- if (comment.anchored_text && comment.anchored_text === comment.selected_text) {
356
- delete comment.anchored_text;
357
- }
358
- isChanged = true;
359
- }
360
- if (isChanged)
361
- changed++;
362
- }
363
- return changed;
64
+ return reanchorDocumentLines(doc, documentLines, { threshold });
364
65
  }
365
66
  /**
366
67
  * High-level re-anchor for a single sidecar file path.
@@ -394,38 +95,4 @@ export async function reanchorFile(sidecarPath, opts = {}) {
394
95
  // ---------------------------------------------------------------------------
395
96
  // Helpers
396
97
  // ---------------------------------------------------------------------------
397
- /**
398
- * Extract text from document lines at the given position.
399
- * Lines array is 1-based (index 0 is unused), so lines[line] is the content.
400
- */
401
- function extractText(lines, line, endLine, startColumn, endColumn) {
402
- const startIdx = line; // 1-based array: lines[1] = first line
403
- const endIdx = endLine ?? line;
404
- if (startIdx < 1 || endIdx >= lines.length)
405
- return null;
406
- if (startIdx === endIdx) {
407
- const text = lines[startIdx];
408
- if (startColumn != null && endColumn != null) {
409
- return text.slice(startColumn, endColumn);
410
- }
411
- return text;
412
- }
413
- // Multi-line
414
- const result = [];
415
- for (let i = startIdx; i <= endIdx; i++) {
416
- let l = lines[i];
417
- if (i === startIdx && startColumn != null)
418
- l = l.slice(startColumn);
419
- if (i === endIdx && endColumn != null)
420
- l = l.slice(0, endColumn);
421
- result.push(l);
422
- }
423
- return result.join("\n");
424
- }
425
- /**
426
- * Pick the candidate closest to a hint line.
427
- */
428
- function closestToLine(candidates, targetLine) {
429
- return candidates.reduce((best, c) => Math.abs(c.line - targetLine) < Math.abs(best.line - targetLine) ? c : best);
430
- }
431
98
  //# sourceMappingURL=reanchor.js.map
@@ -30,6 +30,10 @@ export interface Comment {
30
30
  /** Catch-all for unknown / x_-prefixed extension fields. */
31
31
  [key: string]: unknown;
32
32
  }
33
+ export type CommentExtensionValue = null | boolean | number | string | CommentExtensionValue[] | {
34
+ [key: string]: CommentExtensionValue;
35
+ };
36
+ export type CommentExtensions = Record<`x_${string}`, CommentExtensionValue>;
33
37
  export interface MrsfConfig {
34
38
  sidecar_root?: string;
35
39
  }
@@ -106,6 +110,8 @@ export interface AddCommentOptions {
106
110
  id?: string;
107
111
  /** Override auto-generated timestamp. */
108
112
  timestamp?: string;
113
+ /** Tool-specific extension fields keyed by x_*. */
114
+ extensions?: CommentExtensions;
109
115
  }
110
116
  export interface CommentFilter {
111
117
  open?: boolean;
@@ -150,6 +150,16 @@ function valueToSource(v) {
150
150
  function valueType(v) {
151
151
  return valueToSource(v).startsWith('"') ? "double-quoted-scalar" : "scalar";
152
152
  }
153
+ function isStructuredValue(value) {
154
+ if (value == null)
155
+ return false;
156
+ if (Array.isArray(value))
157
+ return true;
158
+ return typeof value === "object" && !(value instanceof Date);
159
+ }
160
+ function hasStructuredCommentExtensions(doc) {
161
+ return doc.comments.some((comment) => Object.entries(comment).some(([key, value]) => !COMMENT_KEY_ORDER.includes(key) && value !== undefined && isStructuredValue(value)));
162
+ }
153
163
  /* ·· CST node construction ············································ */
154
164
  const NL = "\n";
155
165
  /**
@@ -351,17 +361,21 @@ export async function writeSidecar(filePath, doc) {
351
361
  */
352
362
  async function writeSidecarInternal(abs, doc) {
353
363
  const isJson = abs.endsWith(".review.json");
364
+ for (const comment of doc.comments)
365
+ syncHash(comment);
354
366
  if (isJson) {
355
- // For JSON, always sync hashes and write fresh
356
- for (const comment of doc.comments)
357
- syncHash(comment);
367
+ // For JSON, always write fresh
358
368
  await atomicWriteFile(abs, toJson(doc));
359
369
  return;
360
370
  }
371
+ // The CST patcher only supports scalar map values. Fall back to full YAML
372
+ // serialisation when comment extensions include arrays or objects.
373
+ if (hasStructuredCommentExtensions(doc)) {
374
+ await atomicWriteFile(abs, toYaml(doc));
375
+ return;
376
+ }
361
377
  // ── YAML round-trip path ──────────────────────────────────────────
362
378
  if (!existsSync(abs)) {
363
- for (const comment of doc.comments)
364
- syncHash(comment);
365
379
  await atomicWriteFile(abs, toYaml(doc));
366
380
  return;
367
381
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mrsf/cli",
3
- "version": "0.3.1",
3
+ "version": "0.4.1",
4
4
  "description": "CLI and library for the Markdown Review Sidecar Format (MRSF)",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -9,6 +9,10 @@
9
9
  ".": {
10
10
  "import": "./dist/index.js",
11
11
  "types": "./dist/index.d.ts"
12
+ },
13
+ "./browser": {
14
+ "import": "./dist/browser.js",
15
+ "types": "./dist/browser.d.ts"
12
16
  }
13
17
  },
14
18
  "bin": {
@@ -72,8 +76,8 @@
72
76
  "@types/js-yaml": "^4.0.9",
73
77
  "@types/node": "^22.13.10",
74
78
  "@types/uuid": "^10.0.0",
75
- "@vitest/coverage-v8": "^3.2.4",
79
+ "@vitest/coverage-v8": "^4.0.18",
76
80
  "typescript": "^5.8.2",
77
- "vitest": "^3.0.9"
81
+ "vitest": "^4.0.18"
78
82
  }
79
83
  }