@mrsf/cli 0.2.1 → 0.2.3

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
@@ -242,6 +242,48 @@ mrsf rename docs/old-name.md docs/new-name.md
242
242
 
243
243
  Updates the `document` field inside the sidecar, writes it to the new path, and removes the old sidecar file.
244
244
 
245
+ ### `mrsf watch [files...]`
246
+
247
+ Watch files for changes and continuously validate (and optionally reanchor).
248
+
249
+ ```bash
250
+ # Validate all sidecars on every change
251
+ mrsf watch
252
+
253
+ # Watch specific files
254
+ mrsf watch docs/api.md docs/guide.md
255
+
256
+ # Auto-reanchor when Markdown files change
257
+ mrsf watch --reanchor
258
+
259
+ # Preview reanchor changes without writing
260
+ mrsf watch --reanchor --dry-run
261
+
262
+ # Custom threshold and strict validation
263
+ mrsf watch --reanchor --threshold 0.8 --strict
264
+ ```
265
+
266
+ **Trigger rules** (avoids feedback loops):
267
+
268
+ | File type | Action |
269
+ |---|---|
270
+ | `.md` (Markdown) | Reanchor (if `--reanchor` is set), then validate sidecar |
271
+ | `.review.yaml` / `.review.json` | Validate only |
272
+
273
+ | Option | Description |
274
+ |---|---|
275
+ | `--reanchor` | Auto-reanchor when Markdown files change |
276
+ | `-n, --dry-run` | Preview reanchor changes without writing |
277
+ | `-t, --threshold <n>` | Fuzzy match threshold 0.0–1.0 (default `0.6`) |
278
+ | `-s, --strict` | Treat validation warnings as errors |
279
+ | `--no-git` | Disable git integration |
280
+ | `--from <commit>` | Override from-commit for reanchor |
281
+ | `--update-text` | Update `selected_text` on reanchor |
282
+ | `-f, --force` | Force reanchor — update commit, clear audit fields |
283
+ | `--debounce <ms>` | Debounce interval in ms (default `300`) |
284
+
285
+ Press `Ctrl+C` to stop — a summary of events, reanchors, and errors is printed.
286
+
245
287
  ## Global Options
246
288
 
247
289
  These apply to all commands:
package/dist/bin.js CHANGED
@@ -17,6 +17,7 @@ import { registerList } from "./commands/list.js";
17
17
  import { registerInit } from "./commands/init.js";
18
18
  import { registerStatus } from "./commands/status.js";
19
19
  import { registerRename } from "./commands/rename.js";
20
+ import { registerWatch } from "./commands/watch.js";
20
21
  /* ── Banner ────────────────────────────────────────────────────────── */
21
22
  /**
22
23
  * Detect CI/CD or non-interactive environments where the banner should
@@ -73,5 +74,6 @@ registerList(program);
73
74
  registerInit(program);
74
75
  registerStatus(program);
75
76
  registerRename(program);
77
+ registerWatch(program);
76
78
  program.parse();
77
79
  //# sourceMappingURL=bin.js.map
@@ -0,0 +1,24 @@
1
+ /**
2
+ * mrsf watch — Monitor files and validate / reanchor on change.
3
+ *
4
+ * By default, validation is run on every change. Pass `--reanchor` to
5
+ * also auto-fix drifted anchors when a Markdown file is saved.
6
+ *
7
+ * Trigger rules (avoids feedback loops):
8
+ * - `.md` change → reanchor (if enabled), then validate sidecar
9
+ * - `.review.yaml/json` change → validate only
10
+ */
11
+ import type { Command } from "commander";
12
+ export interface WatchOptions {
13
+ reanchor?: boolean;
14
+ dryRun?: boolean;
15
+ threshold: string;
16
+ git: boolean;
17
+ from?: string;
18
+ updateText?: boolean;
19
+ force?: boolean;
20
+ strict?: boolean;
21
+ debounce: string;
22
+ }
23
+ export declare function registerWatch(program: Command): void;
24
+ //# sourceMappingURL=watch.d.ts.map
@@ -0,0 +1,277 @@
1
+ /**
2
+ * mrsf watch — Monitor files and validate / reanchor on change.
3
+ *
4
+ * By default, validation is run on every change. Pass `--reanchor` to
5
+ * also auto-fix drifted anchors when a Markdown file is saved.
6
+ *
7
+ * Trigger rules (avoids feedback loops):
8
+ * - `.md` change → reanchor (if enabled), then validate sidecar
9
+ * - `.review.yaml/json` change → validate only
10
+ */
11
+ import path from "node:path";
12
+ import chalk from "chalk";
13
+ import { watch as chokidarWatch } from "chokidar";
14
+ import { findWorkspaceRoot, discoverSidecar, sidecarToDocument, } from "../lib/discovery.js";
15
+ import { resolveSidecarPaths } from "../lib/resolve-files.js";
16
+ import { validateFile } from "../lib/validator.js";
17
+ import { reanchorFile } from "../lib/reanchor.js";
18
+ // ---------------------------------------------------------------------------
19
+ // Helpers
20
+ // ---------------------------------------------------------------------------
21
+ const SIDECAR_EXTS = [".review.yaml", ".review.json"];
22
+ function isSidecar(file) {
23
+ return SIDECAR_EXTS.some((ext) => file.endsWith(ext));
24
+ }
25
+ function timestamp() {
26
+ return new Date().toLocaleTimeString("en-GB", { hour12: false });
27
+ }
28
+ function statusIcon(status) {
29
+ switch (status) {
30
+ case "anchored":
31
+ return chalk.green("●");
32
+ case "shifted":
33
+ return chalk.blue("↕");
34
+ case "fuzzy":
35
+ return chalk.yellow("≈");
36
+ case "ambiguous":
37
+ return chalk.magenta("?");
38
+ case "orphaned":
39
+ return chalk.red("✗");
40
+ default:
41
+ return "·";
42
+ }
43
+ }
44
+ function printReanchorResults(label, results, changed, written, dryRun, verbose) {
45
+ const prefix = dryRun ? chalk.dim("[dry-run] ") : "";
46
+ console.log(` ${prefix}${chalk.cyan("reanchor")} ${label}`);
47
+ for (const r of results) {
48
+ const icon = statusIcon(r.status);
49
+ const line = r.newLine != null ? `:${r.newLine}` : "";
50
+ console.log(` ${icon} ${r.commentId}${line} — ${r.reason}`);
51
+ if (verbose && r.score != null) {
52
+ console.log(` score: ${r.score.toFixed(3)}`);
53
+ }
54
+ }
55
+ if (!dryRun && written) {
56
+ console.log(` ${chalk.green("✓")} ${changed} comment(s) updated`);
57
+ }
58
+ else if (dryRun) {
59
+ console.log(` ${chalk.dim(`${changed} comment(s) would change`)}`);
60
+ }
61
+ }
62
+ function printValidateResults(label, result, strict) {
63
+ const effectiveValid = strict
64
+ ? result.valid && result.warnings.length === 0
65
+ : result.valid;
66
+ const icon = effectiveValid ? chalk.green("✓") : chalk.red("✗");
67
+ console.log(` ${chalk.cyan("validate")} ${icon} ${label}`);
68
+ for (const e of result.errors) {
69
+ console.log(chalk.red(` error: ${e.message}${e.path ? ` (${e.path})` : ""}`));
70
+ }
71
+ for (const w of result.warnings) {
72
+ const fn = strict ? chalk.red : chalk.yellow;
73
+ const tag = strict ? "error" : "warn";
74
+ console.log(fn(` ${tag}: ${w.message}${w.path ? ` (${w.path})` : ""}`));
75
+ }
76
+ }
77
+ export function registerWatch(program) {
78
+ program
79
+ .command("watch [files...]")
80
+ .description("Watch files for changes and validate (+ optionally reanchor)")
81
+ .option("--reanchor", "Auto-reanchor when Markdown files change")
82
+ .option("-n, --dry-run", "Preview reanchor changes without writing")
83
+ .option("-t, --threshold <n>", "Fuzzy match threshold 0.0–1.0", "0.6")
84
+ .option("-s, --strict", "Treat validation warnings as errors")
85
+ .option("--no-git", "Disable git integration")
86
+ .option("--from <commit>", "Override from-commit for reanchor")
87
+ .option("--update-text", "Update selected_text on reanchor")
88
+ .option("-f, --force", "Force reanchor — update commit, clear audit fields")
89
+ .option("--debounce <ms>", "Debounce interval in ms", "300")
90
+ .action(async (files, opts) => {
91
+ const parentOpts = program.opts();
92
+ const cwd = parentOpts.cwd ?? process.cwd();
93
+ const quiet = parentOpts.quiet ?? false;
94
+ const verbose = parentOpts.verbose ?? false;
95
+ const debounceMs = parseInt(opts.debounce, 10) || 300;
96
+ const threshold = parseFloat(opts.threshold);
97
+ const noGit = !opts.git;
98
+ // ── Resolve watch targets ────────────────────────────
99
+ let watchPaths;
100
+ if (files.length === 0) {
101
+ // Watch the whole workspace
102
+ const root = findWorkspaceRoot(cwd) ?? cwd;
103
+ watchPaths = [root];
104
+ }
105
+ else {
106
+ // Resolve explicit paths — collect both sidecars and documents
107
+ const sidecarPaths = await resolveSidecarPaths(files, cwd);
108
+ const docPaths = sidecarPaths.map((sp) => sidecarToDocument(sp));
109
+ watchPaths = [...new Set([...sidecarPaths, ...docPaths])];
110
+ }
111
+ // ── Start watcher ────────────────────────────────────
112
+ const watcher = chokidarWatch(watchPaths, {
113
+ ignoreInitial: true,
114
+ ignored: [
115
+ "**/node_modules/**",
116
+ "**/.git/**",
117
+ ...(files.length === 0
118
+ ? []
119
+ : [
120
+ // When watching specific files, chokidar might get
121
+ // extra events from parent dirs — no extra ignores needed.
122
+ ]),
123
+ ],
124
+ // Only watch files matching our extensions when watching a dir
125
+ ...(files.length === 0
126
+ ? {}
127
+ : {}),
128
+ });
129
+ // Track debounce per file
130
+ const timers = new Map();
131
+ // Track files we just wrote (to skip re-trigger)
132
+ const selfWrites = new Set();
133
+ // Stats
134
+ let totalEvents = 0;
135
+ let totalErrors = 0;
136
+ let totalReanchors = 0;
137
+ // ── Event handler ────────────────────────────────────
138
+ async function handleChange(filePath) {
139
+ const abs = path.resolve(filePath);
140
+ const rel = path.relative(cwd, abs);
141
+ // Skip files we just wrote ourselves
142
+ if (selfWrites.has(abs)) {
143
+ selfWrites.delete(abs);
144
+ return;
145
+ }
146
+ // Only process markdown and sidecar files
147
+ const isSidecarFile = isSidecar(abs);
148
+ const isMarkdown = abs.endsWith(".md");
149
+ if (!isSidecarFile && !isMarkdown)
150
+ return;
151
+ totalEvents++;
152
+ if (!quiet) {
153
+ console.log(`\n${chalk.dim(`[${timestamp()}]`)} ${chalk.bold(rel)}`);
154
+ }
155
+ if (isSidecarFile) {
156
+ // ── Sidecar changed → validate ──────────────────
157
+ await runValidate(abs, rel);
158
+ }
159
+ else if (isMarkdown) {
160
+ // ── Markdown changed → reanchor (opt-in) + validate sidecar ──
161
+ let sidecarPath;
162
+ try {
163
+ sidecarPath = await discoverSidecar(abs, { cwd });
164
+ }
165
+ catch {
166
+ // No sidecar for this markdown file — skip silently
167
+ if (verbose) {
168
+ console.log(chalk.dim(` no sidecar found — skipping`));
169
+ }
170
+ return;
171
+ }
172
+ if (opts.reanchor) {
173
+ await runReanchor(sidecarPath, rel);
174
+ }
175
+ // Validate the sidecar in all modes
176
+ await runValidate(sidecarPath, path.relative(cwd, sidecarPath));
177
+ }
178
+ }
179
+ async function runValidate(sidecarPath, label) {
180
+ try {
181
+ const result = await validateFile(sidecarPath);
182
+ const effectiveValid = opts.strict
183
+ ? result.valid && result.warnings.length === 0
184
+ : result.valid;
185
+ if (!effectiveValid)
186
+ totalErrors++;
187
+ if (!quiet || !effectiveValid) {
188
+ printValidateResults(label, result, !!opts.strict);
189
+ }
190
+ }
191
+ catch (err) {
192
+ totalErrors++;
193
+ const msg = err instanceof Error ? err.message : String(err);
194
+ console.error(chalk.red(` validate ✗ ${label}: ${msg}`));
195
+ }
196
+ }
197
+ async function runReanchor(sidecarPath, label) {
198
+ try {
199
+ const { results, changed, written } = await reanchorFile(sidecarPath, {
200
+ cwd,
201
+ dryRun: opts.dryRun,
202
+ threshold,
203
+ noGit,
204
+ fromCommit: opts.from,
205
+ updateText: opts.updateText,
206
+ force: opts.force,
207
+ });
208
+ totalReanchors++;
209
+ if (written) {
210
+ // Mark the sidecar as self-written to avoid re-triggering
211
+ selfWrites.add(path.resolve(sidecarPath));
212
+ }
213
+ const orphaned = results.filter((r) => r.status === "orphaned").length;
214
+ if (orphaned > 0)
215
+ totalErrors++;
216
+ if (!quiet) {
217
+ printReanchorResults(label, results, changed, written, !!opts.dryRun, verbose);
218
+ }
219
+ }
220
+ catch (err) {
221
+ totalErrors++;
222
+ const msg = err instanceof Error ? err.message : String(err);
223
+ console.error(chalk.red(` reanchor ✗ ${label}: ${msg}`));
224
+ }
225
+ }
226
+ // ── Wire events with debounce ────────────────────────
227
+ function onFileChange(filePath) {
228
+ const abs = path.resolve(filePath);
229
+ const existing = timers.get(abs);
230
+ if (existing)
231
+ clearTimeout(existing);
232
+ timers.set(abs, setTimeout(() => {
233
+ timers.delete(abs);
234
+ handleChange(abs).catch((err) => {
235
+ console.error(chalk.red(` unexpected error: ${String(err)}`));
236
+ });
237
+ }, debounceMs));
238
+ }
239
+ watcher.on("change", onFileChange);
240
+ watcher.on("add", onFileChange);
241
+ // ── Startup banner ───────────────────────────────────
242
+ if (!quiet) {
243
+ const mode = opts.reanchor
244
+ ? opts.dryRun
245
+ ? "validate + reanchor (dry-run)"
246
+ : "validate + reanchor"
247
+ : "validate";
248
+ const pathDesc = files.length === 0
249
+ ? "workspace"
250
+ : `${watchPaths.length} path(s)`;
251
+ console.log(chalk.green(`Watching ${pathDesc} — mode: ${chalk.bold(mode)}`));
252
+ console.log(chalk.dim("Press Ctrl+C to stop.\n"));
253
+ }
254
+ // ── Graceful shutdown ────────────────────────────────
255
+ async function shutdown() {
256
+ // Clear pending debounce timers
257
+ for (const t of timers.values())
258
+ clearTimeout(t);
259
+ timers.clear();
260
+ await watcher.close();
261
+ if (!quiet) {
262
+ console.log(`\n${chalk.dim("──────────────────────────────────────")}`);
263
+ console.log(`Events processed: ${totalEvents} | ` +
264
+ `Reanchors: ${totalReanchors} | ` +
265
+ `Errors: ${totalErrors}`);
266
+ }
267
+ process.exit(totalErrors > 0 ? 1 : 0);
268
+ }
269
+ process.on("SIGINT", () => void shutdown());
270
+ process.on("SIGTERM", () => void shutdown());
271
+ // Keep the process alive
272
+ await new Promise(() => {
273
+ // Never resolves — the process runs until interrupted
274
+ });
275
+ });
276
+ }
277
+ //# sourceMappingURL=watch.js.map
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
  // ---------------------------------------------------------------------------
@@ -393,6 +393,45 @@ export async function writeSidecar(filePath, doc) {
393
393
  }
394
394
  }
395
395
  seq.items = newSeqItems;
396
+ // ── Fix first-item indentation after reorder / removal ────────────
397
+ //
398
+ // In the YAML CST, the `comments:` map-entry's `sep` array often
399
+ // contains trailing whitespace (space tokens) that provide the indent
400
+ // *before* the first seq-item indicator (`-`). Non-first seq items
401
+ // carry their own leading space tokens in `start`.
402
+ //
403
+ // When the original first item is removed and a formerly non-first
404
+ // item becomes the new first, those extra space tokens stack with
405
+ // the sep whitespace, doubling the indent. We fix this by stripping
406
+ // any leading space tokens (before the `seq-item-ind`) from the new
407
+ // first item's start — the sep already provides that whitespace.
408
+ //
409
+ // We also ensure the previous item's value ends with a newline so
410
+ // subsequent items render on their own line.
411
+ // ──────────────────────────────────────────────────────────────────
412
+ if (newSeqItems.length > 0) {
413
+ const originalFirstIdx = seq.items === newSeqItems ? 0 : -1; // always true after assignment
414
+ const origFirstItem = existingById.size > 0
415
+ ? [...existingById.values()].find((e) => e.index === 0)
416
+ : undefined;
417
+ const newFirst = newSeqItems[0];
418
+ // Check if the new first item is NOT the original first item
419
+ const isOriginalFirst = origFirstItem &&
420
+ seq.items[0]?.value === origFirstItem.map;
421
+ if (!isOriginalFirst && newFirst.start) {
422
+ // Strip leading space tokens before the seq-item-ind
423
+ const dashIdx = newFirst.start.findIndex((t) => t.type === "seq-item-ind");
424
+ if (dashIdx > 0) {
425
+ // Remove all tokens before the dash that are space/newline
426
+ const toRemove = newFirst.start
427
+ .slice(0, dashIdx)
428
+ .every((t) => t.type === "space" || t.type === "newline");
429
+ if (toRemove) {
430
+ newFirst.start.splice(0, dashIdx);
431
+ }
432
+ }
433
+ }
434
+ }
396
435
  // ── Stringify via CST (byte-identical for untouched content) ───────
397
436
  const result = tokens.map((t) => CST.stringify(t)).join("");
398
437
  await writeFile(abs, result, "utf-8");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mrsf/cli",
3
- "version": "0.2.1",
3
+ "version": "0.2.3",
4
4
  "description": "CLI and library for the Markdown Review Sidecar Format (MRSF)",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -55,6 +55,7 @@
55
55
  "ajv": "^8.17.1",
56
56
  "ajv-formats": "^3.0.1",
57
57
  "chalk": "^5.4.1",
58
+ "chokidar": "^4.0.3",
58
59
  "commander": "^13.1.0",
59
60
  "fastest-levenshtein": "^1.0.16",
60
61
  "js-yaml": "^4.1.0",