@mrsf/cli 0.2.2 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +51 -2
- 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 +2 -1
- package/dist/index.js +1 -1
- package/dist/lib/parser.d.ts +22 -1
- package/dist/lib/parser.js +126 -0
- package/dist/lib/writer.d.ts +3 -0
- package/dist/lib/writer.js +147 -18
- package/package.json +2 -1
package/README.md
CHANGED
|
@@ -1,6 +1,11 @@
|
|
|
1
1
|
# MRSF CLI
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
[](LICENSE)
|
|
4
|
+
[](MRSF-v1.0.md)
|
|
5
|
+
[](https://www.npmjs.com/package/@mrsf/cli)
|
|
6
|
+
[](https://www.npmjs.com/package/@mrsf/cli)
|
|
7
|
+
|
|
8
|
+
Command-line tool and Node.js library for **Sidemark** — the **Markdown Review Sidecar Format** ([MRSF v1.0](../MRSF-v1.0.md)).
|
|
4
9
|
|
|
5
10
|
Validate sidecars, add and resolve review comments, re-anchor comments after document edits, and integrate MRSF into CI/CD pipelines — all from the terminal.
|
|
6
11
|
|
|
@@ -242,6 +247,48 @@ mrsf rename docs/old-name.md docs/new-name.md
|
|
|
242
247
|
|
|
243
248
|
Updates the `document` field inside the sidecar, writes it to the new path, and removes the old sidecar file.
|
|
244
249
|
|
|
250
|
+
### `mrsf watch [files...]`
|
|
251
|
+
|
|
252
|
+
Watch files for changes and continuously validate (and optionally reanchor).
|
|
253
|
+
|
|
254
|
+
```bash
|
|
255
|
+
# Validate all sidecars on every change
|
|
256
|
+
mrsf watch
|
|
257
|
+
|
|
258
|
+
# Watch specific files
|
|
259
|
+
mrsf watch docs/api.md docs/guide.md
|
|
260
|
+
|
|
261
|
+
# Auto-reanchor when Markdown files change
|
|
262
|
+
mrsf watch --reanchor
|
|
263
|
+
|
|
264
|
+
# Preview reanchor changes without writing
|
|
265
|
+
mrsf watch --reanchor --dry-run
|
|
266
|
+
|
|
267
|
+
# Custom threshold and strict validation
|
|
268
|
+
mrsf watch --reanchor --threshold 0.8 --strict
|
|
269
|
+
```
|
|
270
|
+
|
|
271
|
+
**Trigger rules** (avoids feedback loops):
|
|
272
|
+
|
|
273
|
+
| File type | Action |
|
|
274
|
+
|---|---|
|
|
275
|
+
| `.md` (Markdown) | Reanchor (if `--reanchor` is set), then validate sidecar |
|
|
276
|
+
| `.review.yaml` / `.review.json` | Validate only |
|
|
277
|
+
|
|
278
|
+
| Option | Description |
|
|
279
|
+
|---|---|
|
|
280
|
+
| `--reanchor` | Auto-reanchor when Markdown files change |
|
|
281
|
+
| `-n, --dry-run` | Preview reanchor changes without writing |
|
|
282
|
+
| `-t, --threshold <n>` | Fuzzy match threshold 0.0–1.0 (default `0.6`) |
|
|
283
|
+
| `-s, --strict` | Treat validation warnings as errors |
|
|
284
|
+
| `--no-git` | Disable git integration |
|
|
285
|
+
| `--from <commit>` | Override from-commit for reanchor |
|
|
286
|
+
| `--update-text` | Update `selected_text` on reanchor |
|
|
287
|
+
| `-f, --force` | Force reanchor — update commit, clear audit fields |
|
|
288
|
+
| `--debounce <ms>` | Debounce interval in ms (default `300`) |
|
|
289
|
+
|
|
290
|
+
Press `Ctrl+C` to stop — a summary of events, reanchors, and errors is printed.
|
|
291
|
+
|
|
245
292
|
## Global Options
|
|
246
293
|
|
|
247
294
|
These apply to all commands:
|
|
@@ -298,16 +345,18 @@ The CLI is built library-first. All functionality is available as importable fun
|
|
|
298
345
|
```typescript
|
|
299
346
|
import {
|
|
300
347
|
parseSidecar,
|
|
348
|
+
parseSidecarLenient,
|
|
301
349
|
writeSidecar,
|
|
302
350
|
validate,
|
|
303
351
|
reanchorDocument,
|
|
304
352
|
addComment,
|
|
305
353
|
resolveComment,
|
|
354
|
+
removeComment,
|
|
306
355
|
filterComments,
|
|
307
356
|
summarize,
|
|
308
357
|
discoverSidecar,
|
|
309
358
|
discoverAllSidecars,
|
|
310
|
-
} from "mrsf";
|
|
359
|
+
} from "@mrsf/cli";
|
|
311
360
|
|
|
312
361
|
// Validate a sidecar programmatically
|
|
313
362
|
const doc = await parseSidecar("docs/api.md.review.yaml");
|
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
|
@@ -7,7 +7,8 @@
|
|
|
7
7
|
export type { MrsfDocument, Comment, 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
|
-
export { parseSidecar, parseSidecarContent, readDocumentLines, } from "./lib/parser.js";
|
|
10
|
+
export { parseSidecar, parseSidecarContent, parseSidecarLenient, parseSidecarContentLenient, readDocumentLines, } from "./lib/parser.js";
|
|
11
|
+
export type { LenientParseResult } from "./lib/parser.js";
|
|
11
12
|
export { computeHash, syncHash, toYaml, toJson, writeSidecar, } from "./lib/writer.js";
|
|
12
13
|
export { validate, validateFile } from "./lib/validator.js";
|
|
13
14
|
export { exactMatch, normalizedMatch, fuzzySearch, combinedScore, } from "./lib/fuzzy.js";
|
package/dist/index.js
CHANGED
|
@@ -9,7 +9,7 @@ export { findWorkspaceRoot, loadConfig, discoverSidecar, sidecarToDocument, disc
|
|
|
9
9
|
// File resolution
|
|
10
10
|
export { resolveSidecarPaths } from "./lib/resolve-files.js";
|
|
11
11
|
// Parsing
|
|
12
|
-
export { parseSidecar, parseSidecarContent, readDocumentLines, } from "./lib/parser.js";
|
|
12
|
+
export { parseSidecar, parseSidecarContent, parseSidecarLenient, parseSidecarContentLenient, readDocumentLines, } from "./lib/parser.js";
|
|
13
13
|
// Writing
|
|
14
14
|
export { computeHash, syncHash, toYaml, toJson, writeSidecar, } from "./lib/writer.js";
|
|
15
15
|
// Validation
|
package/dist/lib/parser.d.ts
CHANGED
|
@@ -1,7 +1,16 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* MRSF Parser — load and parse MRSF sidecar files (YAML or JSON).
|
|
3
3
|
*/
|
|
4
|
-
import type { MrsfDocument } from "./types.js";
|
|
4
|
+
import type { MrsfDocument, Comment } from "./types.js";
|
|
5
|
+
/** Result from a lenient (non-throwing) parse attempt. */
|
|
6
|
+
export interface LenientParseResult {
|
|
7
|
+
/** Fully parsed document, or null if parsing failed entirely. */
|
|
8
|
+
doc: MrsfDocument | null;
|
|
9
|
+
/** If parsing failed or produced warnings, the error message. */
|
|
10
|
+
error?: string;
|
|
11
|
+
/** Comments that could be salvaged from a partially-corrupted sidecar. */
|
|
12
|
+
partialComments?: Comment[];
|
|
13
|
+
}
|
|
5
14
|
/**
|
|
6
15
|
* Parse an MRSF sidecar file from disk.
|
|
7
16
|
*/
|
|
@@ -11,6 +20,18 @@ export declare function parseSidecar(filePath: string): Promise<MrsfDocument>;
|
|
|
11
20
|
* Detects JSON vs YAML based on content or optional filename hint.
|
|
12
21
|
*/
|
|
13
22
|
export declare function parseSidecarContent(content: string, filenameHint?: string): MrsfDocument;
|
|
23
|
+
/**
|
|
24
|
+
* Lenient parse: attempts to parse a sidecar file from disk without
|
|
25
|
+
* throwing. On complete failure, returns `{ doc: null, error }`.
|
|
26
|
+
* On success, returns `{ doc }`. For partially-corrupted YAML (where
|
|
27
|
+
* the top-level parses but some comments are malformed), attempts to
|
|
28
|
+
* salvage individual well-formed comments.
|
|
29
|
+
*/
|
|
30
|
+
export declare function parseSidecarLenient(filePath: string): Promise<LenientParseResult>;
|
|
31
|
+
/**
|
|
32
|
+
* Lenient parse from string content.
|
|
33
|
+
*/
|
|
34
|
+
export declare function parseSidecarContentLenient(content: string, filenameHint?: string): LenientParseResult;
|
|
14
35
|
/**
|
|
15
36
|
* Read a Markdown document from disk and return its lines.
|
|
16
37
|
* Lines are 1-indexed in the returned array (index 0 is unused).
|
package/dist/lib/parser.js
CHANGED
|
@@ -43,6 +43,132 @@ export function parseSidecarContent(content, filenameHint) {
|
|
|
43
43
|
}
|
|
44
44
|
return parsed;
|
|
45
45
|
}
|
|
46
|
+
/**
|
|
47
|
+
* Lenient parse: attempts to parse a sidecar file from disk without
|
|
48
|
+
* throwing. On complete failure, returns `{ doc: null, error }`.
|
|
49
|
+
* On success, returns `{ doc }`. For partially-corrupted YAML (where
|
|
50
|
+
* the top-level parses but some comments are malformed), attempts to
|
|
51
|
+
* salvage individual well-formed comments.
|
|
52
|
+
*/
|
|
53
|
+
export async function parseSidecarLenient(filePath) {
|
|
54
|
+
const abs = path.resolve(filePath);
|
|
55
|
+
let content;
|
|
56
|
+
try {
|
|
57
|
+
content = await readFile(abs, "utf-8");
|
|
58
|
+
}
|
|
59
|
+
catch (e) {
|
|
60
|
+
return { doc: null, error: `Cannot read file: ${e.message}` };
|
|
61
|
+
}
|
|
62
|
+
return parseSidecarContentLenient(content, abs);
|
|
63
|
+
}
|
|
64
|
+
/**
|
|
65
|
+
* Lenient parse from string content.
|
|
66
|
+
*/
|
|
67
|
+
export function parseSidecarContentLenient(content, filenameHint) {
|
|
68
|
+
const trimmed = content.trim();
|
|
69
|
+
if (!trimmed) {
|
|
70
|
+
return { doc: null, error: "File is empty" };
|
|
71
|
+
}
|
|
72
|
+
const isJson = trimmed.startsWith("{") ||
|
|
73
|
+
(filenameHint && filenameHint.endsWith(".review.json"));
|
|
74
|
+
// First, try a normal parse
|
|
75
|
+
let parsed;
|
|
76
|
+
try {
|
|
77
|
+
parsed = isJson ? JSON.parse(trimmed) : yaml.load(trimmed);
|
|
78
|
+
}
|
|
79
|
+
catch (e) {
|
|
80
|
+
// Total parse failure — try to salvage what we can from YAML
|
|
81
|
+
if (!isJson) {
|
|
82
|
+
return salvageYaml(trimmed);
|
|
83
|
+
}
|
|
84
|
+
return { doc: null, error: `Failed to parse JSON: ${e.message}` };
|
|
85
|
+
}
|
|
86
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
87
|
+
return { doc: null, error: "MRSF sidecar must be a YAML/JSON object" };
|
|
88
|
+
}
|
|
89
|
+
const obj = parsed;
|
|
90
|
+
const doc = {
|
|
91
|
+
mrsf_version: typeof obj.mrsf_version === "string" ? obj.mrsf_version : "1.0",
|
|
92
|
+
document: typeof obj.document === "string" ? obj.document : "unknown",
|
|
93
|
+
comments: [],
|
|
94
|
+
};
|
|
95
|
+
if (!Array.isArray(obj.comments)) {
|
|
96
|
+
return {
|
|
97
|
+
doc,
|
|
98
|
+
error: "comments field is not an array — file may be corrupted",
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
// Validate each comment individually
|
|
102
|
+
const good = [];
|
|
103
|
+
const bad = [];
|
|
104
|
+
for (let i = 0; i < obj.comments.length; i++) {
|
|
105
|
+
const c = obj.comments[i];
|
|
106
|
+
if (c && typeof c === "object" && !Array.isArray(c) && typeof c.id === "string") {
|
|
107
|
+
good.push(c);
|
|
108
|
+
}
|
|
109
|
+
else {
|
|
110
|
+
bad.push(i);
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
doc.comments = good;
|
|
114
|
+
if (bad.length > 0) {
|
|
115
|
+
return {
|
|
116
|
+
doc,
|
|
117
|
+
error: `${bad.length} comment(s) at indices [${bad.join(", ")}] were malformed and skipped`,
|
|
118
|
+
partialComments: good,
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
return { doc };
|
|
122
|
+
}
|
|
123
|
+
/**
|
|
124
|
+
* Attempt to extract individual comment blocks from corrupted YAML by
|
|
125
|
+
* splitting on `- id:` patterns and parsing each block independently.
|
|
126
|
+
*/
|
|
127
|
+
function salvageYaml(content) {
|
|
128
|
+
const salvaged = [];
|
|
129
|
+
let mrsf_version = "1.0";
|
|
130
|
+
let document = "unknown";
|
|
131
|
+
// Try to extract top-level fields from the beginning
|
|
132
|
+
const versionMatch = content.match(/^mrsf_version:\s*["']?([^"'\n]+)/m);
|
|
133
|
+
if (versionMatch)
|
|
134
|
+
mrsf_version = versionMatch[1].trim();
|
|
135
|
+
const docMatch = content.match(/^document:\s*["']?([^"'\n]+)/m);
|
|
136
|
+
if (docMatch)
|
|
137
|
+
document = docMatch[1].trim();
|
|
138
|
+
// Split on comment block boundaries (- id: ...)
|
|
139
|
+
const blocks = content.split(/(?=^ - id:\s)/m);
|
|
140
|
+
for (const block of blocks) {
|
|
141
|
+
const trimmed = block.trim();
|
|
142
|
+
if (!trimmed.startsWith("- id:"))
|
|
143
|
+
continue;
|
|
144
|
+
// Wrap in a minimal YAML array context and try to parse
|
|
145
|
+
try {
|
|
146
|
+
const parsed = yaml.load(trimmed);
|
|
147
|
+
if (Array.isArray(parsed) && parsed.length > 0) {
|
|
148
|
+
const c = parsed[0];
|
|
149
|
+
if (c && typeof c === "object" && typeof c.id === "string") {
|
|
150
|
+
salvaged.push(c);
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
else if (parsed && typeof parsed === "object" && typeof parsed.id === "string") {
|
|
154
|
+
salvaged.push(parsed);
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
catch {
|
|
158
|
+
// This block is unparseable — skip
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
const doc = {
|
|
162
|
+
mrsf_version,
|
|
163
|
+
document,
|
|
164
|
+
comments: salvaged,
|
|
165
|
+
};
|
|
166
|
+
return {
|
|
167
|
+
doc: salvaged.length > 0 ? doc : null,
|
|
168
|
+
error: `YAML parse failed. Salvaged ${salvaged.length} comment(s) from raw content.`,
|
|
169
|
+
partialComments: salvaged.length > 0 ? salvaged : undefined,
|
|
170
|
+
};
|
|
171
|
+
}
|
|
46
172
|
/**
|
|
47
173
|
* Read a Markdown document from disk and return its lines.
|
|
48
174
|
* Lines are 1-indexed in the returned array (index 0 is unused).
|
package/dist/lib/writer.d.ts
CHANGED
|
@@ -37,6 +37,9 @@ export declare function toJson(doc: MrsfDocument): string;
|
|
|
37
37
|
* whitespace byte-for-byte for unchanged content.
|
|
38
38
|
*
|
|
39
39
|
* For new files or JSON, writes from scratch.
|
|
40
|
+
*
|
|
41
|
+
* Writes to the same file path are serialized (queued) to prevent race
|
|
42
|
+
* conditions from concurrent calls. All writes are atomic (temp + rename).
|
|
40
43
|
*/
|
|
41
44
|
export declare function writeSidecar(filePath: string, doc: MrsfDocument): Promise<void>;
|
|
42
45
|
//# sourceMappingURL=writer.d.ts.map
|
package/dist/lib/writer.js
CHANGED
|
@@ -10,12 +10,62 @@
|
|
|
10
10
|
*
|
|
11
11
|
* Auto-computes selected_text_hash when selected_text changes.
|
|
12
12
|
*/
|
|
13
|
-
import { readFile, writeFile } from "node:fs/promises";
|
|
13
|
+
import { readFile, writeFile, rename, unlink } from "node:fs/promises";
|
|
14
14
|
import { existsSync } from "node:fs";
|
|
15
|
-
import { createHash } from "node:crypto";
|
|
15
|
+
import { createHash, randomBytes } from "node:crypto";
|
|
16
16
|
import path from "node:path";
|
|
17
17
|
import { Document, Parser, CST, parse as yamlParse } from "yaml";
|
|
18
18
|
/* ------------------------------------------------------------------ */
|
|
19
|
+
/* Per-file write serialization */
|
|
20
|
+
/* ------------------------------------------------------------------ */
|
|
21
|
+
/**
|
|
22
|
+
* Map of absolute file paths to pending write promises.
|
|
23
|
+
* Ensures that concurrent writes to the same sidecar file are serialized
|
|
24
|
+
* (queued), preventing race conditions during the read-modify-write cycle.
|
|
25
|
+
*/
|
|
26
|
+
const writeQueue = new Map();
|
|
27
|
+
/**
|
|
28
|
+
* Enqueue a write operation for the given file path, ensuring only one
|
|
29
|
+
* write executes at a time per file.
|
|
30
|
+
*/
|
|
31
|
+
function enqueueWrite(abs, fn) {
|
|
32
|
+
const prev = writeQueue.get(abs) ?? Promise.resolve();
|
|
33
|
+
const next = prev.then(fn, fn); // run fn even if previous write failed
|
|
34
|
+
writeQueue.set(abs, next);
|
|
35
|
+
// Clean up map entry when the queue drains
|
|
36
|
+
next.then(() => {
|
|
37
|
+
if (writeQueue.get(abs) === next)
|
|
38
|
+
writeQueue.delete(abs);
|
|
39
|
+
}, () => {
|
|
40
|
+
if (writeQueue.get(abs) === next)
|
|
41
|
+
writeQueue.delete(abs);
|
|
42
|
+
});
|
|
43
|
+
return next;
|
|
44
|
+
}
|
|
45
|
+
/* ------------------------------------------------------------------ */
|
|
46
|
+
/* Atomic file write */
|
|
47
|
+
/* ------------------------------------------------------------------ */
|
|
48
|
+
/**
|
|
49
|
+
* Atomically write content to a file by writing to a temporary file in
|
|
50
|
+
* the same directory, then renaming. On POSIX this is atomic; on
|
|
51
|
+
* Windows it replaces the target.
|
|
52
|
+
*/
|
|
53
|
+
async function atomicWriteFile(filePath, content) {
|
|
54
|
+
const tmp = filePath + "." + randomBytes(6).toString("hex") + ".tmp";
|
|
55
|
+
try {
|
|
56
|
+
await writeFile(tmp, content, "utf-8");
|
|
57
|
+
await rename(tmp, filePath);
|
|
58
|
+
}
|
|
59
|
+
catch (err) {
|
|
60
|
+
// Clean up temp file on failure
|
|
61
|
+
try {
|
|
62
|
+
await unlink(tmp);
|
|
63
|
+
}
|
|
64
|
+
catch { /* ignore */ }
|
|
65
|
+
throw err;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
/* ------------------------------------------------------------------ */
|
|
19
69
|
/* Hash helpers */
|
|
20
70
|
/* ------------------------------------------------------------------ */
|
|
21
71
|
/**
|
|
@@ -51,22 +101,46 @@ const COMMENT_KEY_ORDER = [
|
|
|
51
101
|
* Convert a JS value to its YAML source text representation.
|
|
52
102
|
* Strings that require quoting get double-quoted; numbers and booleans
|
|
53
103
|
* are plain scalars.
|
|
104
|
+
*
|
|
105
|
+
* Uses an allowlist approach: only strings that consist entirely of
|
|
106
|
+
* "safe" characters are emitted as bare scalars. Everything else is
|
|
107
|
+
* double-quoted via JSON.stringify, which is always valid YAML.
|
|
54
108
|
*/
|
|
55
109
|
function valueToSource(v) {
|
|
56
110
|
if (typeof v === "number" || typeof v === "boolean")
|
|
57
111
|
return String(v);
|
|
58
112
|
if (typeof v === "string") {
|
|
59
|
-
//
|
|
60
|
-
if (
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
v === "
|
|
65
|
-
v === "
|
|
66
|
-
|
|
113
|
+
// Empty string must be quoted
|
|
114
|
+
if (v === "")
|
|
115
|
+
return JSON.stringify(v);
|
|
116
|
+
// YAML reserved words / null aliases must be quoted
|
|
117
|
+
if (v === "true" || v === "false" || v === "null" ||
|
|
118
|
+
v === "True" || v === "False" || v === "Null" ||
|
|
119
|
+
v === "TRUE" || v === "FALSE" || v === "NULL" ||
|
|
120
|
+
v === "yes" || v === "no" || v === "on" || v === "off" ||
|
|
121
|
+
v === "Yes" || v === "No" || v === "On" || v === "Off" ||
|
|
122
|
+
v === "YES" || v === "NO" || v === "ON" || v === "OFF" ||
|
|
123
|
+
v === "~" || v === ".inf" || v === "-.inf" || v === ".nan") {
|
|
67
124
|
return JSON.stringify(v);
|
|
68
125
|
}
|
|
69
|
-
|
|
126
|
+
// Strings starting with digits (could be parsed as number) must be quoted
|
|
127
|
+
if (/^\d/.test(v))
|
|
128
|
+
return JSON.stringify(v);
|
|
129
|
+
// Allowlist: safe plain scalars consist of word chars, hyphens (not
|
|
130
|
+
// leading), dots, slashes, spaces (not leading/trailing), parentheses,
|
|
131
|
+
// and @ — but NO YAML indicators or ambiguous sequences.
|
|
132
|
+
// A safe string:
|
|
133
|
+
// • starts with a letter, underscore
|
|
134
|
+
// • contains only [A-Za-z0-9 _./()@+-] (note: hyphen mid-string is OK)
|
|
135
|
+
// • does NOT contain " #" (inline comment)
|
|
136
|
+
// • does NOT start with "- " or end with ":"
|
|
137
|
+
// • has no leading/trailing whitespace
|
|
138
|
+
// • contains no newlines or tabs
|
|
139
|
+
if (/^[A-Za-z_][A-Za-z0-9 _./()\-@+]*$/.test(v) && !v.includes(" #") && !/\s$/.test(v)) {
|
|
140
|
+
return v;
|
|
141
|
+
}
|
|
142
|
+
// Everything else: double-quote (JSON.stringify handles escaping)
|
|
143
|
+
return JSON.stringify(v);
|
|
70
144
|
}
|
|
71
145
|
return String(v);
|
|
72
146
|
}
|
|
@@ -262,22 +336,31 @@ export function toJson(doc) {
|
|
|
262
336
|
* whitespace byte-for-byte for unchanged content.
|
|
263
337
|
*
|
|
264
338
|
* For new files or JSON, writes from scratch.
|
|
339
|
+
*
|
|
340
|
+
* Writes to the same file path are serialized (queued) to prevent race
|
|
341
|
+
* conditions from concurrent calls. All writes are atomic (temp + rename).
|
|
265
342
|
*/
|
|
266
343
|
export async function writeSidecar(filePath, doc) {
|
|
267
344
|
const abs = path.resolve(filePath);
|
|
345
|
+
return enqueueWrite(abs, () => writeSidecarInternal(abs, doc));
|
|
346
|
+
}
|
|
347
|
+
/**
|
|
348
|
+
* Internal implementation of writeSidecar (runs inside the per-file queue).
|
|
349
|
+
*/
|
|
350
|
+
async function writeSidecarInternal(abs, doc) {
|
|
268
351
|
const isJson = abs.endsWith(".review.json");
|
|
269
352
|
if (isJson) {
|
|
270
353
|
// For JSON, always sync hashes and write fresh
|
|
271
354
|
for (const comment of doc.comments)
|
|
272
355
|
syncHash(comment);
|
|
273
|
-
await
|
|
356
|
+
await atomicWriteFile(abs, toJson(doc));
|
|
274
357
|
return;
|
|
275
358
|
}
|
|
276
359
|
// ── YAML round-trip path ──────────────────────────────────────────
|
|
277
360
|
if (!existsSync(abs)) {
|
|
278
361
|
for (const comment of doc.comments)
|
|
279
362
|
syncHash(comment);
|
|
280
|
-
await
|
|
363
|
+
await atomicWriteFile(abs, toYaml(doc));
|
|
281
364
|
return;
|
|
282
365
|
}
|
|
283
366
|
let raw;
|
|
@@ -285,7 +368,7 @@ export async function writeSidecar(filePath, doc) {
|
|
|
285
368
|
raw = await readFile(abs, "utf-8");
|
|
286
369
|
}
|
|
287
370
|
catch {
|
|
288
|
-
await
|
|
371
|
+
await atomicWriteFile(abs, toYaml(doc));
|
|
289
372
|
return;
|
|
290
373
|
}
|
|
291
374
|
// Parse to CST tokens
|
|
@@ -295,7 +378,7 @@ export async function writeSidecar(filePath, doc) {
|
|
|
295
378
|
}
|
|
296
379
|
catch {
|
|
297
380
|
// Unparseable — write fresh
|
|
298
|
-
await
|
|
381
|
+
await atomicWriteFile(abs, toYaml(doc));
|
|
299
382
|
return;
|
|
300
383
|
}
|
|
301
384
|
// Also parse to plain JS object for value comparison (so we compare
|
|
@@ -307,6 +390,13 @@ export async function writeSidecar(filePath, doc) {
|
|
|
307
390
|
catch {
|
|
308
391
|
// ignore — we'll fall through to source-level comparison
|
|
309
392
|
}
|
|
393
|
+
// ── Structural validation of parsed YAML ──────────────────────────
|
|
394
|
+
// If CST parsed OK but the content isn't a valid MrsfDocument shape
|
|
395
|
+
// (e.g. comments is not an array due to injection), discard and write fresh.
|
|
396
|
+
if (currentDoc && (!Array.isArray(currentDoc.comments))) {
|
|
397
|
+
await atomicWriteFile(abs, toYaml(doc));
|
|
398
|
+
return;
|
|
399
|
+
}
|
|
310
400
|
// Build lookup of current parsed values by comment id
|
|
311
401
|
const currentById = new Map();
|
|
312
402
|
if (currentDoc?.comments) {
|
|
@@ -340,7 +430,7 @@ export async function writeSidecar(filePath, doc) {
|
|
|
340
430
|
}
|
|
341
431
|
const docToken = findCstDocument(tokens);
|
|
342
432
|
if (!docToken?.value || docToken.value.type !== "block-map") {
|
|
343
|
-
await
|
|
433
|
+
await atomicWriteFile(abs, toYaml(doc));
|
|
344
434
|
return;
|
|
345
435
|
}
|
|
346
436
|
const body = docToken.value;
|
|
@@ -357,7 +447,7 @@ export async function writeSidecar(filePath, doc) {
|
|
|
357
447
|
const commentsEntry = findCstMapEntry(body, "comments");
|
|
358
448
|
if (!commentsEntry?.value || commentsEntry.value.type !== "block-seq") {
|
|
359
449
|
// No existing comments seq — write fresh
|
|
360
|
-
await
|
|
450
|
+
await atomicWriteFile(abs, toYaml(doc));
|
|
361
451
|
return;
|
|
362
452
|
}
|
|
363
453
|
const seq = commentsEntry.value;
|
|
@@ -393,8 +483,47 @@ export async function writeSidecar(filePath, doc) {
|
|
|
393
483
|
}
|
|
394
484
|
}
|
|
395
485
|
seq.items = newSeqItems;
|
|
486
|
+
// ── Fix first-item indentation after reorder / removal ────────────
|
|
487
|
+
//
|
|
488
|
+
// In the YAML CST, the `comments:` map-entry's `sep` array often
|
|
489
|
+
// contains trailing whitespace (space tokens) that provide the indent
|
|
490
|
+
// *before* the first seq-item indicator (`-`). Non-first seq items
|
|
491
|
+
// carry their own leading space tokens in `start`.
|
|
492
|
+
//
|
|
493
|
+
// When the original first item is removed and a formerly non-first
|
|
494
|
+
// item becomes the new first, those extra space tokens stack with
|
|
495
|
+
// the sep whitespace, doubling the indent. We fix this by stripping
|
|
496
|
+
// any leading space tokens (before the `seq-item-ind`) from the new
|
|
497
|
+
// first item's start — the sep already provides that whitespace.
|
|
498
|
+
//
|
|
499
|
+
// We also ensure the previous item's value ends with a newline so
|
|
500
|
+
// subsequent items render on their own line.
|
|
501
|
+
// ──────────────────────────────────────────────────────────────────
|
|
502
|
+
if (newSeqItems.length > 0) {
|
|
503
|
+
const originalFirstIdx = seq.items === newSeqItems ? 0 : -1; // always true after assignment
|
|
504
|
+
const origFirstItem = existingById.size > 0
|
|
505
|
+
? [...existingById.values()].find((e) => e.index === 0)
|
|
506
|
+
: undefined;
|
|
507
|
+
const newFirst = newSeqItems[0];
|
|
508
|
+
// Check if the new first item is NOT the original first item
|
|
509
|
+
const isOriginalFirst = origFirstItem &&
|
|
510
|
+
seq.items[0]?.value === origFirstItem.map;
|
|
511
|
+
if (!isOriginalFirst && newFirst.start) {
|
|
512
|
+
// Strip leading space tokens before the seq-item-ind
|
|
513
|
+
const dashIdx = newFirst.start.findIndex((t) => t.type === "seq-item-ind");
|
|
514
|
+
if (dashIdx > 0) {
|
|
515
|
+
// Remove all tokens before the dash that are space/newline
|
|
516
|
+
const toRemove = newFirst.start
|
|
517
|
+
.slice(0, dashIdx)
|
|
518
|
+
.every((t) => t.type === "space" || t.type === "newline");
|
|
519
|
+
if (toRemove) {
|
|
520
|
+
newFirst.start.splice(0, dashIdx);
|
|
521
|
+
}
|
|
522
|
+
}
|
|
523
|
+
}
|
|
524
|
+
}
|
|
396
525
|
// ── Stringify via CST (byte-identical for untouched content) ───────
|
|
397
526
|
const result = tokens.map((t) => CST.stringify(t)).join("");
|
|
398
|
-
await
|
|
527
|
+
await atomicWriteFile(abs, result);
|
|
399
528
|
}
|
|
400
529
|
//# sourceMappingURL=writer.js.map
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@mrsf/cli",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
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",
|