@mrsf/cli 0.2.0 → 0.2.2

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/dist/bin.js CHANGED
File without changes
@@ -8,7 +8,7 @@ import { readDocumentLines } from "../lib/parser.js";
8
8
  import { sidecarToDocument } from "../lib/discovery.js";
9
9
  import { resolveSidecarPaths } from "../lib/resolve-files.js";
10
10
  import { writeSidecar } from "../lib/writer.js";
11
- import { findRepoRoot, getStagedFiles } from "../lib/git.js";
11
+ import { findRepoRoot, getStagedFiles, getCurrentCommit } from "../lib/git.js";
12
12
  import { reanchorDocument, applyReanchorResults, } from "../lib/reanchor.js";
13
13
  export function registerReanchor(program) {
14
14
  program
@@ -20,6 +20,7 @@ export function registerReanchor(program) {
20
20
  .option("--no-git", "Disable git integration")
21
21
  .option("--from <commit>", "Override from-commit for all comments")
22
22
  .option("--update-text", "Also update selected_text to match current document text")
23
+ .option("-f, --force", "Firmly anchor high-confidence results (update commit to HEAD, clear audit fields)")
23
24
  .action(async (files, opts) => {
24
25
  const parentOpts = program.opts();
25
26
  const cwd = parentOpts.cwd ?? process.cwd();
@@ -70,6 +71,7 @@ export function registerReanchor(program) {
70
71
  const docPath = sidecarToDocument(sp);
71
72
  const lines = await readDocumentLines(docPath);
72
73
  const repoRoot = !noGit ? await findRepoRoot(cwd) : null;
74
+ const headCommit = repoRoot ? await getCurrentCommit(repoRoot) : undefined;
73
75
  const results = await reanchorDocument(doc, lines, {
74
76
  threshold,
75
77
  noGit,
@@ -82,6 +84,8 @@ export function registerReanchor(program) {
82
84
  if (!opts.dryRun) {
83
85
  const changed = applyReanchorResults(doc, results, {
84
86
  updateText: opts.updateText,
87
+ force: opts.force,
88
+ headCommit: headCommit ?? undefined,
85
89
  });
86
90
  totalChanged += changed;
87
91
  if (changed > 0) {
@@ -93,6 +97,8 @@ export function registerReanchor(program) {
93
97
  const clone = structuredClone(doc);
94
98
  totalChanged += applyReanchorResults(clone, results, {
95
99
  updateText: opts.updateText,
100
+ force: opts.force,
101
+ headCommit: headCommit ?? undefined,
96
102
  });
97
103
  }
98
104
  if (!quiet) {
package/dist/index.d.ts CHANGED
@@ -14,5 +14,5 @@ export { exactMatch, normalizedMatch, fuzzySearch, combinedScore, } from "./lib/
14
14
  export { isGitAvailable, findRepoRoot, getCurrentCommit, isStale, getDiff, getLineShift, getFileAtCommit, getStagedFiles, detectRenames, parseDiffHunks, } from "./lib/git.js";
15
15
  export { reanchorComment, reanchorDocument, applyReanchorResults, reanchorFile, } from "./lib/reanchor.js";
16
16
  export { addComment, populateSelectedText, resolveComment, unresolveComment, removeComment, filterComments, getThreads, summarize, } from "./lib/comments.js";
17
- export type { CommentSummary } from "./lib/comments.js";
17
+ export type { CommentSummary, RemoveCommentOptions } from "./lib/comments.js";
18
18
  //# sourceMappingURL=index.d.ts.map
@@ -23,10 +23,23 @@ export declare function resolveComment(doc: MrsfDocument, commentId: string, cas
23
23
  * Unresolve a comment by id.
24
24
  */
25
25
  export declare function unresolveComment(doc: MrsfDocument, commentId: string): boolean;
26
+ export interface RemoveCommentOptions {
27
+ /**
28
+ * When true, also delete all direct replies instead of promoting them.
29
+ * Default: false (promote replies per §9.1).
30
+ */
31
+ cascade?: boolean;
32
+ }
26
33
  /**
27
- * Remove a comment by id. Returns true if found and removed.
34
+ * Remove a comment by id. Per §9.1, direct replies are promoted:
35
+ * their anchor fields are inherited from the parent (when absent),
36
+ * and their `reply_to` is re-pointed to the parent's parent (or cleared).
37
+ *
38
+ * If `cascade` is true, direct replies are removed along with the parent.
39
+ *
40
+ * Returns true if the comment was found and removed.
28
41
  */
29
- export declare function removeComment(doc: MrsfDocument, commentId: string): boolean;
42
+ export declare function removeComment(doc: MrsfDocument, commentId: string, opts?: RemoveCommentOptions): boolean;
30
43
  /**
31
44
  * Filter comments based on criteria.
32
45
  */
@@ -122,14 +122,58 @@ export function unresolveComment(doc, commentId) {
122
122
  // ---------------------------------------------------------------------------
123
123
  // Remove
124
124
  // ---------------------------------------------------------------------------
125
+ /** Anchor fields that a reply may inherit from its parent on deletion. */
126
+ const ANCHOR_FIELDS = [
127
+ "line",
128
+ "end_line",
129
+ "start_column",
130
+ "end_column",
131
+ "selected_text",
132
+ "selected_text_hash",
133
+ "anchored_text",
134
+ "commit",
135
+ ];
125
136
  /**
126
- * Remove a comment by id. Returns true if found and removed.
137
+ * Remove a comment by id. Per §9.1, direct replies are promoted:
138
+ * their anchor fields are inherited from the parent (when absent),
139
+ * and their `reply_to` is re-pointed to the parent's parent (or cleared).
140
+ *
141
+ * If `cascade` is true, direct replies are removed along with the parent.
142
+ *
143
+ * Returns true if the comment was found and removed.
127
144
  */
128
- export function removeComment(doc, commentId) {
129
- const idx = doc.comments.findIndex((c) => c.id === commentId);
130
- if (idx === -1)
145
+ export function removeComment(doc, commentId, opts) {
146
+ const comment = doc.comments.find((c) => c.id === commentId);
147
+ if (!comment)
131
148
  return false;
132
- doc.comments.splice(idx, 1);
149
+ if (opts?.cascade) {
150
+ // Remove direct replies first
151
+ doc.comments = doc.comments.filter((c) => c.id === commentId || c.reply_to !== commentId);
152
+ }
153
+ else {
154
+ // Promote direct replies (§9.1)
155
+ for (const c of doc.comments) {
156
+ if (c.reply_to !== commentId)
157
+ continue;
158
+ // Copy missing anchor fields from the parent
159
+ for (const field of ANCHOR_FIELDS) {
160
+ if (c[field] == null && comment[field] != null) {
161
+ c[field] = comment[field];
162
+ }
163
+ }
164
+ // Re-point reply_to to grandparent (or clear if parent was root)
165
+ if (comment.reply_to) {
166
+ c.reply_to = comment.reply_to;
167
+ }
168
+ else {
169
+ delete c.reply_to;
170
+ }
171
+ }
172
+ }
173
+ // Remove the parent comment itself
174
+ const idx = doc.comments.findIndex((c) => c.id === commentId);
175
+ if (idx !== -1)
176
+ doc.comments.splice(idx, 1);
133
177
  return true;
134
178
  }
135
179
  // ---------------------------------------------------------------------------
@@ -33,6 +33,8 @@ export declare function reanchorDocument(doc: MrsfDocument, documentLines: strin
33
33
  */
34
34
  export declare function applyReanchorResults(doc: MrsfDocument, results: ReanchorResult[], opts?: {
35
35
  updateText?: boolean;
36
+ force?: boolean;
37
+ headCommit?: string;
36
38
  }): number;
37
39
  /**
38
40
  * High-level re-anchor for a single sidecar file path.
@@ -45,8 +45,24 @@ export function reanchorComment(comment, documentLines, opts = {}) {
45
45
  // -----------------------------------------------------------------------
46
46
  // Step 0: Diff-based shift
47
47
  // -----------------------------------------------------------------------
48
- if (selectedText && comment.line != null && opts.diffHunks?.length) {
48
+ if (comment.line != null && opts.diffHunks?.length) {
49
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
+ }
50
66
  if (!modified) {
51
67
  const shiftedLine = comment.line + shift;
52
68
  const lineSpan = comment.end_line != null ? comment.end_line - comment.line : 0;
@@ -167,13 +183,18 @@ export function reanchorComment(comment, documentLines, opts = {}) {
167
183
  }
168
184
  }
169
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;
170
189
  return {
171
190
  commentId,
172
- status: opts.commitIsStale ? "ambiguous" : "anchored",
173
- score: opts.commitIsStale ? 0.5 : 0.8,
191
+ status: isLineOnly ? "anchored" : (opts.commitIsStale ? "ambiguous" : "anchored"),
192
+ score: isLineOnly ? 1.0 : (opts.commitIsStale ? 0.5 : 0.8),
174
193
  newLine: comment.line,
175
194
  newEndLine: comment.end_line,
176
- reason: `Line/column fallback${qualifier}.`,
195
+ reason: isLineOnly
196
+ ? "Line-only comment (no selected_text to verify)."
197
+ : `Line/column fallback${qualifier}.`,
177
198
  };
178
199
  }
179
200
  }
@@ -320,6 +341,22 @@ export function applyReanchorResults(doc, results, opts = {}) {
320
341
  comment.x_reanchor_status = result.status;
321
342
  comment.x_reanchor_score = result.score;
322
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
+ }
323
360
  if (isChanged)
324
361
  changed++;
325
362
  }
@@ -333,6 +370,7 @@ export async function reanchorFile(sidecarPath, opts = {}) {
333
370
  const docPath = sidecarToDocument(sidecarPath);
334
371
  const documentLines = await readDocumentLines(docPath);
335
372
  const repoRoot = !opts.noGit ? await findRepoRoot(opts.cwd) : null;
373
+ const headCommit = repoRoot ? await getCurrentCommit(repoRoot) : undefined;
336
374
  const results = await reanchorDocument(doc, documentLines, {
337
375
  ...opts,
338
376
  documentPath: docPath,
@@ -341,7 +379,11 @@ export async function reanchorFile(sidecarPath, opts = {}) {
341
379
  let changed = 0;
342
380
  let written = false;
343
381
  if (!opts.dryRun) {
344
- changed = applyReanchorResults(doc, results, { updateText: opts.updateText });
382
+ changed = applyReanchorResults(doc, results, {
383
+ updateText: opts.updateText,
384
+ force: opts.force,
385
+ headCommit: headCommit ?? undefined,
386
+ });
345
387
  if (changed > 0 || opts.autoUpdate) {
346
388
  await writeSidecar(sidecarPath, doc);
347
389
  written = true;
@@ -143,6 +143,8 @@ export interface ReanchorOptions extends BaseOptions {
143
143
  fromCommit?: string;
144
144
  /** Update selected_text to match anchored_text (opt-in per §6.2). */
145
145
  updateText?: boolean;
146
+ /** Force-anchor: update commit to HEAD and clear audit fields for high-confidence results. */
147
+ force?: boolean;
146
148
  }
147
149
  export interface ValidateOptions extends BaseOptions {
148
150
  /** Treat warnings as errors. */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mrsf/cli",
3
- "version": "0.2.0",
3
+ "version": "0.2.2",
4
4
  "description": "CLI and library for the Markdown Review Sidecar Format (MRSF)",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",