@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 +42 -0
- package/dist/bin.js +2 -0
- package/dist/commands/watch.d.ts +24 -0
- package/dist/commands/watch.js +277 -0
- package/dist/index.d.ts +1 -1
- package/dist/lib/comments.d.ts +15 -2
- package/dist/lib/comments.js +49 -5
- package/dist/lib/writer.js +39 -0
- package/package.json +2 -1
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
|
package/dist/lib/comments.d.ts
CHANGED
|
@@ -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.
|
|
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
|
*/
|
package/dist/lib/comments.js
CHANGED
|
@@ -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.
|
|
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
|
|
130
|
-
if (
|
|
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
|
-
|
|
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
|
// ---------------------------------------------------------------------------
|
package/dist/lib/writer.js
CHANGED
|
@@ -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.
|
|
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",
|